[IMP] allow definition of web controllers that match all sub-urls
[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 # Controller registration with a metaclass
354 #----------------------------------------------------------
355 addons_module = {}
356 addons_manifest = {}
357 controllers_class = []
358 controllers_class_path = {}
359 controllers_object = {}
360 controllers_object_path = {}
361 controllers_path = {}
362
363 class ControllerType(type):
364     def __init__(cls, name, bases, attrs):
365         super(ControllerType, cls).__init__(name, bases, attrs)
366         name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
367         controllers_class.append(name_class)
368         path = attrs.get('_cp_path')
369         if path not in controllers_class_path:
370             controllers_class_path[path] = name_class
371
372 class Controller(object):
373     __metaclass__ = ControllerType
374
375     def __new__(cls, *args, **kwargs):
376         subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
377         if subclasses:
378             name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
379             cls = type(name, tuple(reversed(subclasses)), {})
380
381         return object.__new__(cls)
382
383 #----------------------------------------------------------
384 # Session context manager
385 #----------------------------------------------------------
386 @contextlib.contextmanager
387 def session_context(request, session_store, session_lock, sid):
388     with session_lock:
389         if sid:
390             request.session = session_store.get(sid)
391         else:
392             request.session = session_store.new()
393     try:
394         yield request.session
395     finally:
396         # Remove all OpenERPSession instances with no uid, they're generated
397         # either by login process or by HTTP requests without an OpenERP
398         # session id, and are generally noise
399         removed_sessions = set()
400         for key, value in request.session.items():
401             if not isinstance(value, session.OpenERPSession):
402                 continue
403             if getattr(value, '_suicide', False) or (
404                         not value._uid
405                     and not value.jsonp_requests
406                     # FIXME do not use a fixed value
407                     and value._creation_time + (60*5) < time.time()):
408                 _logger.debug('remove session %s', key)
409                 removed_sessions.add(key)
410                 del request.session[key]
411
412         with session_lock:
413             if sid:
414                 # Re-load sessions from storage and merge non-literal
415                 # contexts and domains (they're indexed by hash of the
416                 # content so conflicts should auto-resolve), otherwise if
417                 # two requests alter those concurrently the last to finish
418                 # will overwrite the previous one, leading to loss of data
419                 # (a non-literal is lost even though it was sent to the
420                 # client and client errors)
421                 #
422                 # note that domains_store and contexts_store are append-only (we
423                 # only ever add items to them), so we can just update one with the
424                 # other to get the right result, if we want to merge the
425                 # ``context`` dict we'll need something smarter
426                 in_store = session_store.get(sid)
427                 for k, v in request.session.iteritems():
428                     stored = in_store.get(k)
429                     if stored and isinstance(v, session.OpenERPSession):
430                         if hasattr(v, 'contexts_store'):
431                             del v.contexts_store
432                         if hasattr(v, 'domains_store'):
433                             del v.domains_store
434                         if not hasattr(v, 'jsonp_requests'):
435                             v.jsonp_requests = {}
436                         v.jsonp_requests.update(getattr(
437                             stored, 'jsonp_requests', {}))
438
439                 # add missing keys
440                 for k, v in in_store.iteritems():
441                     if k not in request.session and k not in removed_sessions:
442                         request.session[k] = v
443
444             session_store.save(request.session)
445
446 def session_gc(session_store):
447     if random.random() < 0.001:
448         # we keep session one week
449         last_week = time.time() - 60*60*24*7
450         for fname in os.listdir(session_store.path):
451             path = os.path.join(session_store.path, fname)
452             try:
453                 if os.path.getmtime(path) < last_week:
454                     os.unlink(path)
455             except OSError:
456                 pass
457
458 #----------------------------------------------------------
459 # WSGI Application
460 #----------------------------------------------------------
461 # Add potentially missing (older ubuntu) font mime types
462 mimetypes.add_type('application/font-woff', '.woff')
463 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
464 mimetypes.add_type('application/x-font-ttf', '.ttf')
465
466 class DisableCacheMiddleware(object):
467     def __init__(self, app):
468         self.app = app
469     def __call__(self, environ, start_response):
470         def start_wrapped(status, headers):
471             referer = environ.get('HTTP_REFERER', '')
472             parsed = urlparse.urlparse(referer)
473             debug = parsed.query.count('debug') >= 1
474
475             new_headers = []
476             unwanted_keys = ['Last-Modified']
477             if debug:
478                 new_headers = [('Cache-Control', 'no-cache')]
479                 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
480
481             for k, v in headers:
482                 if k not in unwanted_keys:
483                     new_headers.append((k, v))
484
485             start_response(status, new_headers)
486         return self.app(environ, start_wrapped)
487
488 def session_path():
489     try:
490         username = getpass.getuser()
491     except Exception:
492         username = "unknown"
493     path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
494     try:
495         os.mkdir(path, 0700)
496     except OSError as exc:
497         if exc.errno == errno.EEXIST:
498             # directory exists: ensure it has the correct permissions
499             # this will fail if the directory is not owned by the current user
500             os.chmod(path, 0700)
501         else:
502             raise
503     return path
504
505 class Root(object):
506     """Root WSGI application for the OpenERP Web Client.
507     """
508     def __init__(self):
509         self.addons = {}
510         self.statics = {}
511
512         self.load_addons()
513
514         # Setup http sessions
515         path = session_path()
516         self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
517         self.session_lock = threading.Lock()
518         _logger.debug('HTTP sessions stored in: %s', path)
519
520     def __call__(self, environ, start_response):
521         """ Handle a WSGI request
522         """
523         return self.dispatch(environ, start_response)
524
525     def dispatch(self, environ, start_response):
526         """
527         Performs the actual WSGI dispatching for the application, may be
528         wrapped during the initialization of the object.
529
530         Call the object directly.
531         """
532         request = werkzeug.wrappers.Request(environ)
533         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
534         request.app = self
535
536         handler = self.find_handler(*(request.path.split('/')[1:]))
537
538         if not handler:
539             response = werkzeug.exceptions.NotFound()
540         else:
541             sid = request.cookies.get('sid')
542             if not sid:
543                 sid = request.args.get('sid')
544
545             session_gc(self.session_store)
546
547             with session_context(request, self.session_store, self.session_lock, sid) as session:
548                 result = handler(request)
549
550                 if isinstance(result, basestring):
551                     headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
552                     response = werkzeug.wrappers.Response(result, headers=headers)
553                 else:
554                     response = result
555
556                 if hasattr(response, 'set_cookie'):
557                     response.set_cookie('sid', session.sid)
558
559         return response(environ, start_response)
560
561     def load_addons(self):
562         """ Load all addons from addons patch containg static files and
563         controllers and configure them.  """
564
565         for addons_path in openerp.modules.module.ad_paths:
566             for module in sorted(os.listdir(addons_path)):
567                 if module not in addons_module:
568                     manifest_path = os.path.join(addons_path, module, '__openerp__.py')
569                     path_static = os.path.join(addons_path, module, 'static')
570                     if os.path.isfile(manifest_path) and os.path.isdir(path_static):
571                         manifest = ast.literal_eval(open(manifest_path).read())
572                         manifest['addons_path'] = addons_path
573                         _logger.debug("Loading %s", module)
574                         if 'openerp.addons' in sys.modules:
575                             m = __import__('openerp.addons.' + module)
576                         else:
577                             m = __import__(module)
578                         addons_module[module] = m
579                         addons_manifest[module] = manifest
580                         self.statics['/%s/static' % module] = path_static
581
582         for k, v in controllers_class_path.items():
583             if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
584                 o = v[1]()
585                 controllers_object[v[0]] = o
586                 controllers_object_path[k] = o
587                 if hasattr(o, '_cp_path'):
588                     controllers_path[o._cp_path] = o
589
590         app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
591         self.dispatch = DisableCacheMiddleware(app)
592
593     def find_handler(self, *l):
594         """
595         Tries to discover the controller handling the request for the path
596         specified by the provided parameters
597
598         :param l: path sections to a controller or controller method
599         :returns: a callable matching the path sections, or ``None``
600         :rtype: ``Controller | None``
601         """
602         if l:
603             ps = '/' + '/'.join(filter(None, l))
604             method_name = 'index'
605             while ps:
606                 c = controllers_path.get(ps)
607                 if c:
608                     method = getattr(c, method_name, None)
609                     if method:
610                         exposed = getattr(method, 'exposed', False)
611                         if exposed == 'json':
612                             _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
613                             return lambda request: JsonRequest(request).dispatch(method)
614                         elif exposed == 'http':
615                             _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
616                             return lambda request: HttpRequest(request).dispatch(method)
617                     elif method_name != "index":
618                         method_name = "index"
619                         continue
620                 ps, _slash, method_name = ps.rpartition('/')
621                 if not ps and method_name:
622                     ps = '/'
623         return None
624
625 def wsgi_postload():
626     openerp.wsgi.register_wsgi_handler(Root())
627
628 # vim:et:ts=4:sw=4: