Puts the request in a thread-local stack
[odoo/odoo.git] / addons / web / http.py
1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
5 import ast
6 import cgi
7 import contextlib
8 import functools
9 import getpass
10 import logging
11 import mimetypes
12 import os
13 import pprint
14 import random
15 import sys
16 import tempfile
17 import threading
18 import time
19 import traceback
20 import urlparse
21 import uuid
22 import errno
23
24 import babel.core
25 import simplejson
26 import werkzeug.contrib.sessions
27 import werkzeug.datastructures
28 import werkzeug.exceptions
29 import werkzeug.utils
30 import werkzeug.wrappers
31 import werkzeug.wsgi
32
33 import openerp
34
35 import session
36
37 _logger = logging.getLogger(__name__)
38
39 #----------------------------------------------------------
40 # RequestHandler
41 #----------------------------------------------------------
42 class WebRequest(object):
43     """ Parent class for all OpenERP Web request types, mostly deals with
44     initialization and setup of the request object (the dispatching itself has
45     to be handled by the subclasses)
46
47     :param request: a wrapped werkzeug Request object
48     :type request: :class:`werkzeug.wrappers.BaseRequest`
49
50     .. attribute:: httprequest
51
52         the original :class:`werkzeug.wrappers.Request` object provided to the
53         request
54
55     .. attribute:: httpsession
56
57         a :class:`~collections.Mapping` holding the HTTP session data for the
58         current http session
59
60     .. attribute:: params
61
62         :class:`~collections.Mapping` of request parameters, not generally
63         useful as they're provided directly to the handler method as keyword
64         arguments
65
66     .. attribute:: session_id
67
68         opaque identifier for the :class:`session.OpenERPSession` instance of
69         the current request
70
71     .. attribute:: session
72
73         :class:`~session.OpenERPSession` instance for the current request
74
75     .. attribute:: context
76
77         :class:`~collections.Mapping` of context values for the current request
78
79     .. attribute:: debug
80
81         ``bool``, indicates whether the debug mode is active on the client
82     """
83     def __init__(self, request):
84         self.httprequest = request
85         self.httpresponse = None
86         self.httpsession = request.session
87
88     def init(self, params):
89         self.params = dict(params)
90         # OpenERP session setup
91         self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
92         self.session = self.httpsession.get(self.session_id)
93         if not self.session:
94             self.session = session.OpenERPSession()
95             self.httpsession[self.session_id] = self.session
96
97         # set db/uid trackers - they're cleaned up at the WSGI
98         # dispatching phase in openerp.service.wsgi_server.application
99         if self.session._db:
100             threading.current_thread().dbname = self.session._db
101         if self.session._uid:
102             threading.current_thread().uid = self.session._uid
103
104         self.context = self.params.pop('context', {})
105         self.debug = self.params.pop('debug', False) is not False
106         # Determine self.lang
107         lang = self.params.get('lang', None)
108         if lang is None:
109             lang = self.context.get('lang')
110         if lang is None:
111             lang = self.httprequest.cookies.get('lang')
112         if lang is None:
113             lang = self.httprequest.accept_languages.best
114         if not lang:
115             lang = 'en_US'
116         # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
117         lang = babel.core.LOCALE_ALIASES.get(lang, lang)
118         # we use _ as seprator where RFC2616 uses '-'
119         self.lang = lang.replace('-', '_')
120
121 def reject_nonliteral(dct):
122     if '__ref' in dct:
123         raise ValueError(
124             "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
125     return dct
126
127 class JsonRequest(WebRequest):
128     """ JSON-RPC2 over HTTP.
129
130     Sucessful request::
131
132       --> {"jsonrpc": "2.0",
133            "method": "call",
134            "params": {"session_id": "SID",
135                       "context": {},
136                       "arg1": "val1" },
137            "id": null}
138
139       <-- {"jsonrpc": "2.0",
140            "result": { "res1": "val1" },
141            "id": null}
142
143     Request producing a error::
144
145       --> {"jsonrpc": "2.0",
146            "method": "call",
147            "params": {"session_id": "SID",
148                       "context": {},
149                       "arg1": "val1" },
150            "id": null}
151
152       <-- {"jsonrpc": "2.0",
153            "error": {"code": 1,
154                      "message": "End user error message.",
155                      "data": {"code": "codestring",
156                               "debug": "traceback" } },
157            "id": null}
158
159     """
160     def dispatch(self, method):
161         """ Calls the method asked for by the JSON-RPC2 or JSONP request
162
163         :param method: the method which received the request
164
165         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
166         """
167         args = self.httprequest.args
168         jsonp = args.get('jsonp')
169         requestf = None
170         request = None
171         request_id = args.get('id')
172
173         if jsonp and self.httprequest.method == 'POST':
174             # jsonp 2 steps step1 POST: save call
175             self.init(args)
176             self.session.jsonp_requests[request_id] = self.httprequest.form['r']
177             headers=[('Content-Type', 'text/plain; charset=utf-8')]
178             r = werkzeug.wrappers.Response(request_id, headers=headers)
179             return r
180         elif jsonp and args.get('r'):
181             # jsonp method GET
182             request = args.get('r')
183         elif jsonp and request_id:
184             # jsonp 2 steps step2 GET: run and return result
185             self.init(args)
186             request = self.session.jsonp_requests.pop(request_id, "")
187         else:
188             # regular jsonrpc2
189             requestf = self.httprequest.stream
190
191         response = {"jsonrpc": "2.0" }
192         error = None
193         try:
194             # Read POST content or POST Form Data named "request"
195             if requestf:
196                 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
197             else:
198                 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
199             self.init(self.jsonrequest.get("params", {}))
200             if _logger.isEnabledFor(logging.DEBUG):
201                 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
202             response['id'] = self.jsonrequest.get('id')
203             response["result"] = method(self, **self.params)
204         except session.AuthenticationError, e:
205             se = serialize_exception(e)
206             error = {
207                 'code': 100,
208                 'message': "OpenERP Session Invalid",
209                 'data': se
210             }
211         except Exception, e:
212             se = serialize_exception(e)
213             error = {
214                 'code': 200,
215                 'message': "OpenERP Server Error",
216                 'data': se
217             }
218         if error:
219             response["error"] = error
220
221         if _logger.isEnabledFor(logging.DEBUG):
222             _logger.debug("<--\n%s", pprint.pformat(response))
223
224         if jsonp:
225             # If we use jsonp, that's mean we are called from another host
226             # Some browser (IE and Safari) do no allow third party cookies
227             # We need then to manage http sessions manually.
228             response['httpsessionid'] = self.httpsession.sid
229             mime = 'application/javascript'
230             body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
231         else:
232             mime = 'application/json'
233             body = simplejson.dumps(response)
234
235         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
236         return r
237
238 def serialize_exception(e):
239     tmp = {
240         "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
241         "debug": traceback.format_exc(),
242         "message": u"%s" % e,
243         "arguments": to_jsonable(e.args),
244     }
245     if isinstance(e, openerp.osv.osv.except_osv):
246         tmp["exception_type"] = "except_osv"
247     elif isinstance(e, openerp.exceptions.Warning):
248         tmp["exception_type"] = "warning"
249     elif isinstance(e, openerp.exceptions.AccessError):
250         tmp["exception_type"] = "access_error"
251     elif isinstance(e, openerp.exceptions.AccessDenied):
252         tmp["exception_type"] = "access_denied"
253     return tmp
254
255 def to_jsonable(o):
256     if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
257         or isinstance(o, bool) or o is None or isinstance(o, float):
258         return o
259     if isinstance(o, list) or isinstance(o, tuple):
260         return [to_jsonable(x) for x in o]
261     if isinstance(o, dict):
262         tmp = {}
263         for k, v in o.items():
264             tmp[u"%s" % k] = to_jsonable(v)
265         return tmp
266     return u"%s" % o
267
268 def jsonrequest(f):
269     """ Decorator marking the decorated method as being a handler for a
270     JSON-RPC request (the exact request path is specified via the
271     ``$(Controller._cp_path)/$methodname`` combination.
272
273     If the method is called, it will be provided with a :class:`JsonRequest`
274     instance and all ``params`` sent during the JSON-RPC request, apart from
275     the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
276     beforehand)
277     """
278     f.exposed = 'json'
279     return f
280
281 class HttpRequest(WebRequest):
282     """ Regular GET/POST request
283     """
284     def dispatch(self, method):
285         params = dict(self.httprequest.args)
286         params.update(self.httprequest.form)
287         params.update(self.httprequest.files)
288         self.init(params)
289         akw = {}
290         for key, value in self.httprequest.args.iteritems():
291             if isinstance(value, basestring) and len(value) < 1024:
292                 akw[key] = value
293             else:
294                 akw[key] = type(value)
295         _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
296         try:
297             r = method(self, **self.params)
298         except Exception, e:
299             _logger.exception("An exception occured during an http request")
300             se = serialize_exception(e)
301             error = {
302                 'code': 200,
303                 'message': "OpenERP Server Error",
304                 'data': se
305             }
306             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
307         if self.debug or 1:
308             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
309                 _logger.debug('<-- %s', r)
310             else:
311                 _logger.debug("<-- size: %s", len(r))
312         return r
313
314     def make_response(self, data, headers=None, cookies=None):
315         """ Helper for non-HTML responses, or HTML responses with custom
316         response headers or cookies.
317
318         While handlers can just return the HTML markup of a page they want to
319         send as a string if non-HTML data is returned they need to create a
320         complete response object, or the returned data will not be correctly
321         interpreted by the clients.
322
323         :param basestring data: response body
324         :param headers: HTTP headers to set on the response
325         :type headers: ``[(name, value)]``
326         :param collections.Mapping cookies: cookies to set on the client
327         """
328         response = werkzeug.wrappers.Response(data, headers=headers)
329         if cookies:
330             for k, v in cookies.iteritems():
331                 response.set_cookie(k, v)
332         return response
333
334     def not_found(self, description=None):
335         """ Helper for 404 response, return its result from the method
336         """
337         return werkzeug.exceptions.NotFound(description)
338
339 def httprequest(f):
340     """ Decorator marking the decorated method as being a handler for a
341     normal HTTP request (the exact request path is specified via the
342     ``$(Controller._cp_path)/$methodname`` combination.
343
344     If the method is called, it will be provided with a :class:`HttpRequest`
345     instance and all ``params`` sent during the request (``GET`` and ``POST``
346     merged in the same dictionary), apart from the ``session_id``, ``context``
347     and ``debug`` keys (which are stripped out beforehand)
348     """
349     f.exposed = 'http'
350     return f
351
352 #----------------------------------------------------------
353 # Local storage of requests
354 #----------------------------------------------------------
355 _thlocal = threading.local()
356
357 class RequestProxy(object):
358     def __getattr__(self, name):
359         return getattr(_thlocal.stack[-1], name)
360     def __setattr__(self, name, val):
361         return setattr(_thlocal.stack[-1], name, val)
362     def __delattr__(self, name):
363         return delattr(_thlocal.stack[-1], name)
364     @classmethod
365     def set_request(cls, request):
366         class with_obj:
367             def __enter__(self):
368                 if getattr(_thlocal, "stack", None) is None:
369                     _thlocal.stack = []
370                 _thlocal.stack.append(request)
371             def __exit__(self, *args):
372                 _thlocal.stack.pop()
373         return with_obj()
374
375 request = RequestProxy()
376
377 #----------------------------------------------------------
378 # Controller registration with a metaclass
379 #----------------------------------------------------------
380 addons_module = {}
381 addons_manifest = {}
382 controllers_class = []
383 controllers_class_path = {}
384 controllers_object = {}
385 controllers_object_path = {}
386 controllers_path = {}
387
388 class ControllerType(type):
389     def __init__(cls, name, bases, attrs):
390         super(ControllerType, cls).__init__(name, bases, attrs)
391         name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
392         controllers_class.append(name_class)
393         path = attrs.get('_cp_path')
394         if path not in controllers_class_path:
395             controllers_class_path[path] = name_class
396
397 class Controller(object):
398     __metaclass__ = ControllerType
399
400     def __new__(cls, *args, **kwargs):
401         subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
402         if subclasses:
403             name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
404             cls = type(name, tuple(reversed(subclasses)), {})
405
406         return object.__new__(cls)
407
408 #----------------------------------------------------------
409 # Session context manager
410 #----------------------------------------------------------
411 @contextlib.contextmanager
412 def session_context(request, session_store, session_lock, sid):
413     with session_lock:
414         if sid:
415             request.session = session_store.get(sid)
416         else:
417             request.session = session_store.new()
418     try:
419         yield request.session
420     finally:
421         # Remove all OpenERPSession instances with no uid, they're generated
422         # either by login process or by HTTP requests without an OpenERP
423         # session id, and are generally noise
424         removed_sessions = set()
425         for key, value in request.session.items():
426             if not isinstance(value, session.OpenERPSession):
427                 continue
428             if getattr(value, '_suicide', False) or (
429                         not value._uid
430                     and not value.jsonp_requests
431                     # FIXME do not use a fixed value
432                     and value._creation_time + (60*5) < time.time()):
433                 _logger.debug('remove session %s', key)
434                 removed_sessions.add(key)
435                 del request.session[key]
436
437         with session_lock:
438             if sid:
439                 # Re-load sessions from storage and merge non-literal
440                 # contexts and domains (they're indexed by hash of the
441                 # content so conflicts should auto-resolve), otherwise if
442                 # two requests alter those concurrently the last to finish
443                 # will overwrite the previous one, leading to loss of data
444                 # (a non-literal is lost even though it was sent to the
445                 # client and client errors)
446                 #
447                 # note that domains_store and contexts_store are append-only (we
448                 # only ever add items to them), so we can just update one with the
449                 # other to get the right result, if we want to merge the
450                 # ``context`` dict we'll need something smarter
451                 in_store = session_store.get(sid)
452                 for k, v in request.session.iteritems():
453                     stored = in_store.get(k)
454                     if stored and isinstance(v, session.OpenERPSession):
455                         if hasattr(v, 'contexts_store'):
456                             del v.contexts_store
457                         if hasattr(v, 'domains_store'):
458                             del v.domains_store
459                         if not hasattr(v, 'jsonp_requests'):
460                             v.jsonp_requests = {}
461                         v.jsonp_requests.update(getattr(
462                             stored, 'jsonp_requests', {}))
463
464                 # add missing keys
465                 for k, v in in_store.iteritems():
466                     if k not in request.session and k not in removed_sessions:
467                         request.session[k] = v
468
469             session_store.save(request.session)
470
471 def session_gc(session_store):
472     if random.random() < 0.001:
473         # we keep session one week
474         last_week = time.time() - 60*60*24*7
475         for fname in os.listdir(session_store.path):
476             path = os.path.join(session_store.path, fname)
477             try:
478                 if os.path.getmtime(path) < last_week:
479                     os.unlink(path)
480             except OSError:
481                 pass
482
483 #----------------------------------------------------------
484 # WSGI Application
485 #----------------------------------------------------------
486 # Add potentially missing (older ubuntu) font mime types
487 mimetypes.add_type('application/font-woff', '.woff')
488 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
489 mimetypes.add_type('application/x-font-ttf', '.ttf')
490
491 class DisableCacheMiddleware(object):
492     def __init__(self, app):
493         self.app = app
494     def __call__(self, environ, start_response):
495         def start_wrapped(status, headers):
496             referer = environ.get('HTTP_REFERER', '')
497             parsed = urlparse.urlparse(referer)
498             debug = parsed.query.count('debug') >= 1
499
500             new_headers = []
501             unwanted_keys = ['Last-Modified']
502             if debug:
503                 new_headers = [('Cache-Control', 'no-cache')]
504                 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
505
506             for k, v in headers:
507                 if k not in unwanted_keys:
508                     new_headers.append((k, v))
509
510             start_response(status, new_headers)
511         return self.app(environ, start_wrapped)
512
513 def session_path():
514     try:
515         username = getpass.getuser()
516     except Exception:
517         username = "unknown"
518     path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
519     try:
520         os.mkdir(path, 0700)
521     except OSError as exc:
522         if exc.errno == errno.EEXIST:
523             # directory exists: ensure it has the correct permissions
524             # this will fail if the directory is not owned by the current user
525             os.chmod(path, 0700)
526         else:
527             raise
528     return path
529
530 class Root(object):
531     """Root WSGI application for the OpenERP Web Client.
532     """
533     def __init__(self):
534         self.addons = {}
535         self.statics = {}
536
537         self.load_addons()
538
539         # Setup http sessions
540         path = session_path()
541         self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
542         self.session_lock = threading.Lock()
543         _logger.debug('HTTP sessions stored in: %s', path)
544
545     def __call__(self, environ, start_response):
546         """ Handle a WSGI request
547         """
548         return self.dispatch(environ, start_response)
549
550     def dispatch(self, environ, start_response):
551         """
552         Performs the actual WSGI dispatching for the application, may be
553         wrapped during the initialization of the object.
554
555         Call the object directly.
556         """
557         request = werkzeug.wrappers.Request(environ)
558         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
559         request.app = self
560
561         handler = self.find_handler(*(request.path.split('/')[1:]))
562
563         if not handler:
564             response = werkzeug.exceptions.NotFound()
565         else:
566             sid = request.cookies.get('sid')
567             if not sid:
568                 sid = request.args.get('sid')
569
570             session_gc(self.session_store)
571
572             with session_context(request, self.session_store, self.session_lock, sid) as session:
573                 result = handler(request)
574
575                 if isinstance(result, basestring):
576                     headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
577                     response = werkzeug.wrappers.Response(result, headers=headers)
578                 else:
579                     response = result
580
581                 if hasattr(response, 'set_cookie'):
582                     response.set_cookie('sid', session.sid)
583
584         return response(environ, start_response)
585
586     def load_addons(self):
587         """ Load all addons from addons patch containg static files and
588         controllers and configure them.  """
589
590         for addons_path in openerp.modules.module.ad_paths:
591             for module in sorted(os.listdir(str(addons_path))):
592                 if module not in addons_module:
593                     manifest_path = os.path.join(addons_path, module, '__openerp__.py')
594                     path_static = os.path.join(addons_path, module, 'static')
595                     if os.path.isfile(manifest_path) and os.path.isdir(path_static):
596                         manifest = ast.literal_eval(open(manifest_path).read())
597                         manifest['addons_path'] = addons_path
598                         _logger.debug("Loading %s", module)
599                         if 'openerp.addons' in sys.modules:
600                             m = __import__('openerp.addons.' + module)
601                         else:
602                             m = __import__(module)
603                         addons_module[module] = m
604                         addons_manifest[module] = manifest
605                         self.statics['/%s/static' % module] = path_static
606
607         for k, v in controllers_class_path.items():
608             if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
609                 o = v[1]()
610                 controllers_object[v[0]] = o
611                 controllers_object_path[k] = o
612                 if hasattr(o, '_cp_path'):
613                     controllers_path[o._cp_path] = o
614
615         app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
616         self.dispatch = DisableCacheMiddleware(app)
617
618     def find_handler(self, *l):
619         """
620         Tries to discover the controller handling the request for the path
621         specified by the provided parameters
622
623         :param l: path sections to a controller or controller method
624         :returns: a callable matching the path sections, or ``None``
625         :rtype: ``Controller | None``
626         """
627         if l:
628             ps = '/' + '/'.join(filter(None, l))
629             method_name = 'index'
630             while ps:
631                 c = controllers_path.get(ps)
632                 if c:
633                     method = getattr(c, method_name, None)
634                     if method:
635                         exposed = getattr(method, 'exposed', False)
636                         if exposed == 'json':
637                             _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
638                             def fct(request):
639                                 req = JsonRequest(request)
640                                 with RequestProxy.set_request(req):
641                                     return req.dispatch(method)
642                             return fct
643                         elif exposed == 'http':
644                             _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
645                             def fct(request):
646                                 req = HttpRequest(request)
647                                 with RequestProxy.set_request(req):
648                                     return req.dispatch(method)
649                             return fct
650                     if method_name != "index":
651                         method_name = "index"
652                         continue
653                 ps, _slash, method_name = ps.rpartition('/')
654                 if not ps and method_name:
655                     ps = '/'
656         return None
657
658 def wsgi_postload():
659     openerp.wsgi.register_wsgi_handler(Root())
660
661 # vim:et:ts=4:sw=4: