cubicweb/web/request.py
changeset 11057 0b59724cb3f2
parent 10997 da712d3f0601
child 11174 eb88fdfd3740
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/request.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,1138 @@
+# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""abstract class for http request"""
+
+__docformat__ = "restructuredtext en"
+
+import time
+import random
+import base64
+from hashlib import sha1 # pylint: disable=E0611
+from calendar import timegm
+from datetime import date, datetime
+from warnings import warn
+from io import BytesIO
+
+from six import PY2, binary_type, text_type, string_types
+from six.moves import http_client
+from six.moves.urllib.parse import urlsplit, quote as urlquote
+from six.moves.http_cookies import SimpleCookie
+
+from rql.utils import rqlvar_maker
+
+from logilab.common.decorators import cached
+from logilab.common.deprecation import deprecated
+from logilab.mtconverter import xml_escape
+
+from cubicweb import AuthenticationError
+from cubicweb.req import RequestSessionBase
+from cubicweb.uilib import remove_html_tags, js
+from cubicweb.utils import HTMLHead, make_uid
+from cubicweb.view import TRANSITIONAL_DOCTYPE_NOEXT
+from cubicweb.web import (INTERNAL_FIELD_VALUE, LOGGER, NothingToEdit,
+                          RequestError, StatusResponse)
+from cubicweb.web.httpcache import get_validators
+from cubicweb.web.http_headers import Headers, Cookie, parseDateTime
+
+_MARKER = object()
+
+def build_cb_uid(seed):
+    sha = sha1(('%s%s%s' % (time.time(), seed, random.random())).encode('ascii'))
+    return 'cb_%s' % (sha.hexdigest())
+
+
+def list_form_param(form, param, pop=False):
+    """get param from form parameters and return its value as a list,
+    skipping internal markers if any
+
+    * if the parameter isn't defined, return an empty list
+    * if the parameter is a single (unicode) value, return a list
+      containing that value
+    * if the parameter is already a list or tuple, just skip internal
+      markers
+
+    if pop is True, the parameter is removed from the form dictionary
+    """
+    if pop:
+        try:
+            value = form.pop(param)
+        except KeyError:
+            return []
+    else:
+        value = form.get(param, ())
+    if value is None:
+        value = ()
+    elif not isinstance(value, (list, tuple)):
+        value = [value]
+    return [v for v in value if v != INTERNAL_FIELD_VALUE]
+
+
+class Counter(object):
+    """A picklable counter object, usable for e.g. page tab index count"""
+    __slots__ = ('value',)
+
+    def __init__(self, initialvalue=0):
+        self.value = initialvalue
+
+    def __call__(self):
+        value = self.value
+        self.value += 1
+        return value
+
+    def __getstate__(self):
+        return {'value': self.value}
+
+    def __setstate__(self, state):
+        self.value = state['value']
+
+
+class _CubicWebRequestBase(RequestSessionBase):
+    """abstract HTTP request, should be extended according to the HTTP backend
+    Immutable attributes that describe the received query and generic configuration
+    """
+    ajax_request = False # to be set to True by ajax controllers
+
+    def __init__(self, vreg, https=False, form=None, headers=None):
+        """
+        :vreg: Vregistry,
+        :https: boolean, s this a https request
+        :form: Forms value
+        :headers: dict, request header
+        """
+        super(_CubicWebRequestBase, self).__init__(vreg)
+        #: (Boolean) Is this an https request.
+        self.https = https
+        #: User interface property (vary with https) (see :ref:`uiprops`)
+        self.uiprops = None
+        #: url for serving datadir (vary with https) (see :ref:`resources`)
+        self.datadir_url = None
+        if https and vreg.config.https_uiprops is not None:
+            self.uiprops = vreg.config.https_uiprops
+        else:
+            self.uiprops = vreg.config.uiprops
+        if https and vreg.config.https_datadir_url is not None:
+            self.datadir_url = vreg.config.https_datadir_url
+        else:
+            self.datadir_url = vreg.config.datadir_url
+        #: enable UStringIO's write tracing
+        self.tracehtml = False
+        if vreg.config.debugmode:
+            self.tracehtml = bool(form.pop('_cwtracehtml', False))
+        #: raw html headers that can be added from any view
+        self.html_headers = HTMLHead(self, tracewrites=self.tracehtml)
+        #: received headers
+        self._headers_in = Headers()
+        if headers is not None:
+            for k, v in headers.items():
+                self._headers_in.addRawHeader(k, v)
+        #: form parameters
+        self.setup_params(form)
+        #: received body
+        self.content = BytesIO()
+        # prepare output header
+        #: Header used for the final response
+        self.headers_out = Headers()
+        #: HTTP status use by the final response
+        self.status_out  = 200
+        # set up language based on request headers or site default (we don't
+        # have a user yet, and might not get one)
+        self.set_user_language(None)
+        #: dictionary that may be used to store request data that has to be
+        #: shared among various components used to publish the request (views,
+        #: controller, application...)
+        self.data = {}
+        self._search_state = None
+        #: page id, set by htmlheader template
+        self.pageid = None
+        self._set_pageid()
+
+    def _set_pageid(self):
+        """initialize self.pageid
+        if req.form provides a specific pageid, use it, otherwise build a
+        new one.
+        """
+        pid = self.form.get('pageid')
+        if pid is None:
+            pid = make_uid(id(self))
+            self.html_headers.define_var('pageid', pid, override=False)
+        self.pageid = pid
+
+    def _get_json_request(self):
+        warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
+             DeprecationWarning, stacklevel=2)
+        return self.ajax_request
+    def _set_json_request(self, value):
+        warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
+             DeprecationWarning, stacklevel=2)
+        self.ajax_request = value
+    json_request = property(_get_json_request, _set_json_request)
+
+    def _base_url(self, secure=None):
+        """return the root url of the instance
+
+        secure = False -> base-url
+        secure = None  -> https-url if req.https
+        secure = True  -> https if it exist
+        """
+        if secure is None:
+            secure = self.https
+        base_url = None
+        if secure:
+            base_url = self.vreg.config.get('https-url')
+        if base_url is None:
+            base_url = super(_CubicWebRequestBase, self)._base_url()
+        return base_url
+
+    @property
+    def authmode(self):
+        """Authentification mode of the instance
+        (see :ref:`WebServerConfig`)"""
+        return self.vreg.config['auth-mode']
+
+    # Various variable generator.
+
+    @property
+    def varmaker(self):
+        """the rql varmaker is exposed both as a property and as the
+        set_varmaker function since we've two use cases:
+
+        * accessing the req.varmaker property to get a new variable name
+
+        * calling req.set_varmaker() to ensure a varmaker is set for later ajax
+          calls sharing our .pageid
+        """
+        return self.set_varmaker()
+
+    def next_tabindex(self):
+        nextfunc = self.get_page_data('nexttabfunc')
+        if nextfunc is None:
+            nextfunc = Counter(1)
+            self.set_page_data('nexttabfunc', nextfunc)
+        return nextfunc()
+
+    def set_varmaker(self):
+        varmaker = self.get_page_data('rql_varmaker')
+        if varmaker is None:
+            varmaker = rqlvar_maker()
+            self.set_page_data('rql_varmaker', varmaker)
+        return varmaker
+
+    # input form parameters management ########################################
+
+    # common form parameters which should be protected against html values
+    # XXX can't add 'eid' for instance since it may be multivalued
+    # dont put rql as well, if query contains < and > it will be corrupted!
+    no_script_form_params = set(('vid',
+                                 'etype',
+                                 'vtitle', 'title',
+                                 '__redirectvid', '__redirectrql'))
+
+    def setup_params(self, params):
+        """WARNING: we're intentionally leaving INTERNAL_FIELD_VALUE here
+
+        subclasses should overrides to
+        """
+        self.form = {}
+        if params is None:
+            return
+        encoding = self.encoding
+        for param, val in params.items():
+            if isinstance(val, (tuple, list)):
+                if PY2:
+                    val = [unicode(x, encoding) for x in val]
+                if len(val) == 1:
+                    val = val[0]
+            elif PY2 and isinstance(val, str):
+                val = unicode(val, encoding)
+            if param in self.no_script_form_params and val:
+                val = self.no_script_form_param(param, val)
+            if param == '_cwmsgid':
+                self.set_message_id(val)
+            else:
+                self.form[param] = val
+
+    def no_script_form_param(self, param, value):
+        """ensure there is no script in a user form param
+
+        by default return a cleaned string instead of raising a security
+        exception
+
+        this method should be called on every user input (form at least) fields
+        that are at some point inserted in a generated html page to protect
+        against script kiddies
+        """
+        # safety belt for strange urls like http://...?vtitle=yo&vtitle=yo
+        if isinstance(value, (list, tuple)):
+            self.error('no_script_form_param got a list (%s). Who generated the URL ?',
+                       repr(value))
+            value = value[0]
+        return remove_html_tags(value)
+
+    def list_form_param(self, param, form=None, pop=False):
+        """get param from form parameters and return its value as a list,
+        skipping internal markers if any
+
+        * if the parameter isn't defined, return an empty list
+        * if the parameter is a single (unicode) value, return a list
+          containing that value
+        * if the parameter is already a list or tuple, just skip internal
+          markers
+
+        if pop is True, the parameter is removed from the form dictionary
+        """
+        if form is None:
+            form = self.form
+        return list_form_param(form, param, pop)
+
+    def reset_headers(self):
+        """used by AutomaticWebTest to clear html headers between tests on
+        the same resultset
+        """
+        self.html_headers = HTMLHead(self)
+        return self
+
+    # web state helpers #######################################################
+
+    @property
+    def message(self):
+        try:
+            return self.session.data.pop(self._msgid, u'')
+        except AttributeError:
+            try:
+                return self._msg
+            except AttributeError:
+                return None
+
+    def set_message(self, msg):
+        assert isinstance(msg, text_type)
+        self.reset_message()
+        self._msg = msg
+
+    def set_message_id(self, msgid):
+        self._msgid = msgid
+
+    @cached
+    def redirect_message_id(self):
+        return make_uid()
+
+    def set_redirect_message(self, msg):
+        # TODO - this should probably be merged with append_to_redirect_message
+        assert isinstance(msg, text_type)
+        msgid = self.redirect_message_id()
+        self.session.data[msgid] = msg
+        return msgid
+
+    def append_to_redirect_message(self, msg):
+        msgid = self.redirect_message_id()
+        currentmsg = self.session.data.get(msgid)
+        if currentmsg is not None:
+            currentmsg = u'%s %s' % (currentmsg, msg)
+        else:
+            currentmsg = msg
+        self.session.data[msgid] = currentmsg
+        return msgid
+
+    def reset_message(self):
+        if hasattr(self, '_msg'):
+            del self._msg
+        if hasattr(self, '_msgid'):
+            self.session.data.pop(self._msgid, u'')
+            del self._msgid
+
+    def _load_search_state(self, searchstate):
+        if searchstate is None or searchstate == 'normal':
+            self._search_state = ('normal',)
+        else:
+            self._search_state = ('linksearch', searchstate.split(':'))
+            assert len(self._search_state[-1]) == 4, 'invalid searchstate'
+
+    @property
+    def search_state(self):
+        """search state: 'normal' or 'linksearch' (i.e. searching for an object
+        to create a relation with another)"""
+        if self._search_state is None:
+            searchstate = self.session.data.get('search_state', 'normal')
+            self._load_search_state(searchstate)
+        return self._search_state
+
+    @search_state.setter
+    def search_state(self, searchstate):
+        self._search_state = searchstate
+
+    def update_search_state(self):
+        """update the current search state if needed"""
+        searchstate = self.form.get('__mode')
+        if searchstate:
+            self.set_search_state(searchstate)
+
+    def set_search_state(self, searchstate):
+        """set a new search state"""
+        self.session.data['search_state'] = searchstate
+        self._load_search_state(searchstate)
+
+    def match_search_state(self, rset):
+        """when searching an entity to create a relation, return True if entities in
+        the given rset may be used as relation end
+        """
+        try:
+            searchedtype = self.search_state[1][-1]
+        except IndexError:
+            return False # no searching for association
+        for etype in rset.column_types(0):
+            if etype != searchedtype:
+                return False
+        return True
+
+    # web edition helpers #####################################################
+
+    @cached # so it's writed only once
+    def fckeditor_config(self):
+        fckeditor_url = self.build_url('fckeditor/fckeditor.js')
+        self.add_js(fckeditor_url, localfile=False)
+        self.html_headers.define_var('fcklang', self.lang)
+        self.html_headers.define_var('fckconfigpath',
+                                     self.data_url('cubicweb.fckcwconfig.js'))
+    def use_fckeditor(self):
+        return self.vreg.config.fckeditor_installed() and self.property_value('ui.fckeditor')
+
+    def edited_eids(self, withtype=False):
+        """return a list of edited eids"""
+        yielded = False
+        # warning: use .keys since the caller may change `form`
+        form = self.form
+        try:
+            eids = form['eid']
+        except KeyError:
+            raise NothingToEdit(self._('no selected entities'))
+        if isinstance(eids, string_types):
+            eids = (eids,)
+        for peid in eids:
+            if withtype:
+                typekey = '__type:%s' % peid
+                assert typekey in form, 'no entity type specified'
+                yield peid, form[typekey]
+            else:
+                yield peid
+            yielded = True
+        if not yielded:
+            raise NothingToEdit(self._('no selected entities'))
+
+    # minparams=3 by default: at least eid, __type, and some params to change
+    def extract_entity_params(self, eid, minparams=3):
+        """extract form parameters relative to the given eid"""
+        params = {}
+        eid = str(eid)
+        form = self.form
+        for param in form:
+            try:
+                name, peid = param.split(':', 1)
+            except ValueError:
+                if not param.startswith('__') and param not in ('eid', '_cw_fields'):
+                    self.warning('param %s mis-formatted', param)
+                continue
+            if peid == eid:
+                value = form[param]
+                if value == INTERNAL_FIELD_VALUE:
+                    value = None
+                params[name] = value
+        params['eid'] = eid
+        if len(params) < minparams:
+            raise RequestError(self._('missing parameters for entity %s') % eid)
+        return params
+
+    # XXX this should go to the GenericRelationsField. missing edition cancel protocol.
+
+    def remove_pending_operations(self):
+        """shortcut to clear req's pending_{delete,insert} entries
+
+        This is needed when the edition is completed (whether it's validated
+        or cancelled)
+        """
+        self.session.data.pop('pending_insert', None)
+        self.session.data.pop('pending_delete', None)
+
+    def cancel_edition(self, errorurl):
+        """remove pending operations and `errorurl`'s specific stored data
+        """
+        self.session.data.pop(errorurl, None)
+        self.remove_pending_operations()
+
+    # high level methods for HTTP headers management ##########################
+
+    # must be cached since login/password are popped from the form dictionary
+    # and this method may be called multiple times during authentication
+    @cached
+    def get_authorization(self):
+        """Parse and return the Authorization header"""
+        if self.authmode == "cookie":
+            try:
+                user = self.form.pop("__login")
+                passwd = self.form.pop("__password", '')
+                return user, passwd.encode('UTF8')
+            except KeyError:
+                self.debug('no login/password in form params')
+                return None, None
+        else:
+            return self.header_authorization()
+
+    def get_cookie(self):
+        """retrieve request cookies, returns an empty cookie if not found"""
+        # XXX use http_headers implementation
+        try:
+            return SimpleCookie(self.get_header('Cookie'))
+        except KeyError:
+            return SimpleCookie()
+
+    def set_cookie(self, name, value, maxage=300, expires=None, secure=False, httponly=False):
+        """set / update a cookie
+
+        by default, cookie will be available for the next 5 minutes.
+        Give maxage = None to have a "session" cookie expiring when the
+        client close its browser
+        """
+        if isinstance(name, SimpleCookie):
+            warn('[3.13] set_cookie now takes name and value as two first '
+                 'argument, not anymore cookie object and name',
+                 DeprecationWarning, stacklevel=2)
+            secure = name[value]['secure']
+            name, value = value, name[value].value
+        if maxage: # don't check is None, 0 may be specified
+            assert expires is None, 'both max age and expires cant be specified'
+            expires = maxage + time.time()
+        elif expires:
+            # we don't want to handle times before the EPOCH (cause bug on
+            # windows). Also use > and not >= else expires == 0 and Cookie think
+            # that means no expire...
+            assert expires > date(1970, 1, 1)
+            expires = timegm(expires.timetuple())
+        else:
+            expires = None
+        # make sure cookie is set on the correct path
+        cookie = Cookie(str(name), str(value), self.base_url_path(),
+                        expires=expires, secure=secure, httponly=httponly)
+        self.headers_out.addHeader('Set-cookie', cookie)
+
+    def remove_cookie(self, name, bwcompat=None):
+        """remove a cookie by expiring it"""
+        if bwcompat is not None:
+            warn('[3.13] remove_cookie now take only a name as argument',
+                 DeprecationWarning, stacklevel=2)
+            name = bwcompat
+        self.set_cookie(name, '', maxage=0, expires=date(2000, 1, 1))
+
+    def set_content_type(self, content_type, filename=None, encoding=None,
+                         disposition='inline'):
+        """set output content type for this request. An optional filename
+        may be given.
+
+        The disposition argument may be `attachement` or `inline` as specified
+        for the Content-disposition HTTP header. The disposition parameter have
+        no effect if no filename are specified.
+        """
+        if content_type.startswith('text/') and ';charset=' not in content_type:
+            content_type += ';charset=' + (encoding or self.encoding)
+        self.set_header('content-type', content_type)
+        if filename:
+            header = [disposition]
+            unicode_filename = None
+            try:
+                ascii_filename = filename.encode('ascii').decode('ascii')
+            except UnicodeEncodeError:
+                # fallback filename for very old browser
+                unicode_filename = filename
+                ascii_filename = filename.encode('ascii', 'ignore').decode('ascii')
+            # escape " and \
+            # see http://greenbytes.de/tech/tc2231/#attwithfilenameandextparamescaped
+            ascii_filename = ascii_filename.replace('\x5c', r'\\').replace('"', r'\"')
+            header.append('filename="%s"' % ascii_filename)
+            if unicode_filename is not None:
+                # encoded filename according RFC5987
+                urlquoted_filename = urlquote(unicode_filename.encode('utf-8'), '')
+                header.append("filename*=utf-8''" + urlquoted_filename)
+            self.set_header('content-disposition', ';'.join(header))
+
+    # high level methods for HTML headers management ##########################
+
+    def add_onload(self, jscode):
+        self.html_headers.add_onload(jscode)
+
+    def add_js(self, jsfiles, localfile=True):
+        """specify a list of JS files to include in the HTML headers.
+
+        :param jsfiles: a JS filename or a list of JS filenames
+        :param localfile: if True, the default data dir prefix is added to the
+                          JS filename
+        """
+        if isinstance(jsfiles, string_types):
+            jsfiles = (jsfiles,)
+        for jsfile in jsfiles:
+            if localfile:
+                jsfile = self.data_url(jsfile)
+            self.html_headers.add_js(jsfile)
+
+    def add_css(self, cssfiles, media=u'all', localfile=True, ieonly=False,
+                iespec=u'[if lt IE 8]'):
+        """specify a CSS file to include in the HTML headers
+
+        :param cssfiles: a CSS filename or a list of CSS filenames.
+        :param media: the CSS's media if necessary
+        :param localfile: if True, the default data dir prefix is added to the
+                          CSS filename
+        :param ieonly: True if this css is specific to IE
+        :param iespec: conditional expression that will be used around
+                       the css inclusion. cf:
+                       http://msdn.microsoft.com/en-us/library/ms537512(VS.85).aspx
+        """
+        if isinstance(cssfiles, string_types):
+            cssfiles = (cssfiles,)
+        if ieonly:
+            if self.ie_browser():
+                extraargs = [iespec]
+                add_css = self.html_headers.add_ie_css
+            else:
+                return # no need to do anything on non IE browsers
+        else:
+            extraargs = []
+            add_css = self.html_headers.add_css
+        for cssfile in cssfiles:
+            if localfile:
+                cssfile = self.data_url(cssfile)
+            add_css(cssfile, media, *extraargs)
+
+    def ajax_replace_url(self, nodeid, replacemode='replace', **extraparams):
+        """builds an ajax url that will replace nodeid's content
+
+        :param nodeid: the dom id of the node to replace
+        :param replacemode: defines how the replacement should be done.
+
+          Possible values are :
+          - 'replace' to replace the node's content with the generated HTML
+          - 'swap' to replace the node itself with the generated HTML
+          - 'append' to append the generated HTML to the node's content
+
+        Arbitrary extra named arguments may be given, they will be included as
+        parameters of the generated url.
+        """
+        # define a function in headers and use it in the link to avoid url
+        # unescaping pb: browsers give the js expression to the interpreter
+        # after having url unescaping the content. This may make appear some
+        # quote or other special characters that will break the js expression.
+        extraparams.setdefault('fname', 'view')
+        # remove pageid from the generated URL as it's forced as a parameter
+        # to the loadxhtml call below.
+        extraparams.pop('pageid', None)
+        url = self.build_url('ajax', **extraparams)
+        cbname = build_cb_uid(url[:50])
+        # think to propagate pageid. XXX see https://www.cubicweb.org/ticket/1753121
+        jscode = u'function %s() { $("#%s").%s; }' % (
+            cbname, nodeid, js.loadxhtml(url, {'pageid': self.pageid},
+                                         'get', replacemode))
+        self.html_headers.add_post_inline_script(jscode)
+        return "javascript: %s()" % cbname
+
+    # urls/path management ####################################################
+
+    def build_url(self, *args, **kwargs):
+        """return an absolute URL using params dictionary key/values as URL
+        parameters. Values are automatically URL quoted, and the
+        publishing method to use may be specified or will be guessed.
+        """
+        if '__message' in kwargs:
+            msg = kwargs.pop('__message')
+            kwargs['_cwmsgid'] = self.set_redirect_message(msg)
+        if not args:
+            method = 'view'
+            if (self.from_controller() == 'view'
+                and not '_restpath' in kwargs):
+                method = self.relative_path(includeparams=False) or 'view'
+            args = (method,)
+        return super(_CubicWebRequestBase, self).build_url(*args, **kwargs)
+
+    def url(self, includeparams=True):
+        """return currently accessed url"""
+        return self.base_url() + self.relative_path(includeparams)
+
+    def selected(self, url):
+        """return True if the url is equivalent to currently accessed url"""
+        reqpath = self.relative_path().lower()
+        baselen = len(self.base_url())
+        return (reqpath == url[baselen:].lower())
+
+    def base_url_prepend_host(self, hostname):
+        protocol, roothost = urlsplit(self.base_url())[:2]
+        if roothost.startswith('www.'):
+            roothost = roothost[4:]
+        return '%s://%s.%s' % (protocol, hostname, roothost)
+
+    def base_url_path(self):
+        """returns the absolute path of the base url"""
+        return urlsplit(self.base_url())[2]
+
+    def data_url(self, relpath):
+        """returns the absolute path for a data resource"""
+        return self.datadir_url + relpath
+
+    @cached
+    def from_controller(self):
+        """return the id (string) of the controller issuing the request"""
+        controller = self.relative_path(False).split('/', 1)[0]
+        if controller in self.vreg['controllers']:
+            return controller
+        return 'view'
+
+    def is_client_cache_valid(self):
+        """check if a client cached page exists (as specified in request
+        headers) and is still usable.
+
+        Return False if the page has to be calculated, else True.
+
+        Some response cache headers may be set by this method.
+        """
+        modified = True
+        # Here, we search for any invalid 'not modified' condition
+        # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3
+        validators = get_validators(self._headers_in)
+        if validators: # if we have no
+            modified = any(func(val, self.headers_out) for func, val in validators)
+        # Forge expected response
+        if not modified:
+            # overwrite headers_out to forge a brand new not-modified response
+            self.headers_out = self._forge_cached_headers()
+            if self.http_method() in ('HEAD', 'GET'):
+                self.status_out = http_client.NOT_MODIFIED
+            else:
+                self.status_out = http_client.PRECONDITION_FAILED
+            # XXX replace by True once validate_cache bw compat method is dropped
+            return self.status_out
+        # XXX replace by False once validate_cache bw compat method is dropped
+        return None
+
+    @deprecated('[3.18] use .is_client_cache_valid() method instead')
+    def validate_cache(self):
+        """raise a `StatusResponse` exception if a cached page along the way
+        exists and is still usable.
+        """
+        status_code = self.is_client_cache_valid()
+        if status_code is not None:
+            raise StatusResponse(status_code)
+
+    # abstract methods to override according to the web front-end #############
+
+    def http_method(self):
+        """returns 'POST', 'GET', 'HEAD', etc."""
+        raise NotImplementedError()
+
+    def _forge_cached_headers(self):
+        # overwrite headers_out to forge a brand new not-modified response
+        headers = Headers()
+        for header in (
+            # Required from sec 10.3.5:
+            'date', 'etag', 'content-location', 'expires',
+            'cache-control', 'vary',
+            # Others:
+            'server', 'proxy-authenticate', 'www-authenticate', 'warning'):
+            value = self._headers_in.getRawHeaders(header)
+            if value is not None:
+                headers.setRawHeaders(header, value)
+        return headers
+
+    def relative_path(self, includeparams=True):
+        """return the normalized path of the request (ie at least relative
+        to the instance's root, but some other normalization may be needed
+        so that the returned path may be used to compare to generated urls
+
+        :param includeparams:
+           boolean indicating if GET form parameters should be kept in the path
+        """
+        raise NotImplementedError()
+
+    # http headers ############################################################
+
+    ### incoming headers
+
+    def get_header(self, header, default=None, raw=True):
+        """return the value associated with the given input header, raise
+        KeyError if the header is not set
+        """
+        if raw:
+            return self._headers_in.getRawHeaders(header, [default])[0]
+        return self._headers_in.getHeader(header, default)
+
+    def header_accept_language(self):
+        """returns an ordered list of preferred languages"""
+        acceptedlangs = self.get_header('Accept-Language', raw=False) or {}
+        for lang, _ in sorted(acceptedlangs.items(), key=lambda x: x[1],
+                              reverse=True):
+            lang = lang.split('-')[0]
+            yield lang
+
+    def header_if_modified_since(self):
+        """If the HTTP header If-modified-since is set, return the equivalent
+        date time value (GMT), else return None
+        """
+        mtime = self.get_header('If-modified-since', raw=False)
+        if mtime:
+            return datetime.utcfromtimestamp(mtime)
+        return None
+
+    ### outcoming headers
+    def set_header(self, header, value, raw=True):
+        """set an output HTTP header"""
+        if raw:
+            # adding encoded header is important, else page content
+            # will be reconverted back to unicode and apart unefficiency, this
+            # may cause decoding problem (e.g. when downloading a file)
+            self.headers_out.setRawHeaders(header, [str(value)])
+        else:
+            self.headers_out.setHeader(header, value)
+
+    def add_header(self, header, value):
+        """add an output HTTP header"""
+        # adding encoded header is important, else page content
+        # will be reconverted back to unicode and apart unefficiency, this
+        # may cause decoding problem (e.g. when downloading a file)
+        self.headers_out.addRawHeader(header, str(value))
+
+    def remove_header(self, header):
+        """remove an output HTTP header"""
+        self.headers_out.removeHeader(header)
+
+    def header_authorization(self):
+        """returns a couple (auth-type, auth-value)"""
+        auth = self.get_header("Authorization", None)
+        if auth:
+            scheme, rest = auth.split(' ', 1)
+            scheme = scheme.lower()
+            try:
+                assert scheme == "basic"
+                user, passwd = base64.decodestring(rest.encode('ascii')).split(b":", 1)
+                # XXX HTTP header encoding: use email.Header?
+                return user.decode('UTF8'), passwd
+            except Exception as ex:
+                self.debug('bad authorization %s (%s: %s)',
+                           auth, ex.__class__.__name__, ex)
+        return None, None
+
+    def parse_accept_header(self, header):
+        """returns an ordered list of accepted values"""
+        try:
+            value_parser, value_sort_key = ACCEPT_HEADER_PARSER[header.lower()]
+        except KeyError:
+            value_parser = value_sort_key = None
+        accepteds = self.get_header(header, '')
+        values = _parse_accept_header(accepteds, value_parser, value_sort_key)
+        return (raw_value for (raw_value, parsed_value, score) in values)
+
+    @deprecated('[3.17] demote_to_html is deprecated as we always serve html')
+    def demote_to_html(self):
+        """helper method to dynamically set request content type to text/html
+
+        The global doctype and xmldec must also be changed otherwise the browser
+        will display '<[' at the beginning of the page
+        """
+        pass
+
+
+    # xml doctype #############################################################
+
+    def set_doctype(self, doctype, reset_xmldecl=None):
+        """helper method to dynamically change page doctype
+
+        :param doctype: the new doctype, e.g. '<!DOCTYPE html>'
+        """
+        if reset_xmldecl is not None:
+            warn('[3.17] reset_xmldecl is deprecated as we only serve html',
+                 DeprecationWarning, stacklevel=2)
+        self.main_stream.set_doctype(doctype)
+
+    # page data management ####################################################
+
+    def get_page_data(self, key, default=None):
+        """return value associated to `key` in current page data"""
+        page_data = self.session.data.get(self.pageid)
+        if page_data is None:
+            return default
+        return page_data.get(key, default)
+
+    def set_page_data(self, key, value):
+        """set value associated to `key` in current page data"""
+        self.html_headers.add_unload_pagedata()
+        page_data = self.session.data.setdefault(self.pageid, {})
+        page_data[key] = value
+        self.session.data[self.pageid] = page_data
+
+    def del_page_data(self, key=None):
+        """remove value associated to `key` in current page data
+        if `key` is None, all page data will be cleared
+        """
+        if key is None:
+            self.session.data.pop(self.pageid, None)
+        else:
+            try:
+                del self.session.data[self.pageid][key]
+            except KeyError:
+                pass
+
+    # user-agent detection ####################################################
+
+    @cached
+    def useragent(self):
+        return self.get_header('User-Agent', None)
+
+    def ie_browser(self):
+        useragent = self.useragent()
+        return useragent and 'MSIE' in useragent
+
+    @deprecated('[3.17] xhtml_browser is deprecated (xhtml is no longer served)')
+    def xhtml_browser(self):
+        """return True if the browser is considered as xhtml compatible.
+
+        If the instance is configured to always return text/html and not
+        application/xhtml+xml, this method will always return False, even though
+        this is semantically different
+        """
+        return False
+
+    def html_content_type(self):
+        return 'text/html'
+
+    def set_user_language(self, user):
+        vreg = self.vreg
+        if user is not None:
+            try:
+                # 1. user-specified language
+                lang = vreg.typed_value('ui.language', user.properties['ui.language'])
+                self.set_language(lang)
+                return
+            except KeyError:
+                pass
+        if vreg.config.get('language-negociation', False):
+            # 2. http accept-language
+            self.headers_out.addHeader('Vary', 'Accept-Language')
+            for lang in self.header_accept_language():
+                if lang in self.translations:
+                    self.set_language(lang)
+                    return
+        # 3. site's default language
+        self.set_default_language(vreg)
+
+
+def _cnx_func(name):
+    def proxy(req, *args, **kwargs):
+        return getattr(req.cnx, name)(*args, **kwargs)
+    return proxy
+
+class _NeedAuthAccessMock(object):
+
+    def __getattribute__(self, attr):
+        raise AuthenticationError()
+
+    def __bool__(self):
+        return False
+    
+    __nonzero__ = __bool__
+
+class _MockAnonymousSession(object):
+    sessionid = 'thisisnotarealsession'
+
+    @property
+    def data(self):
+        return {}
+
+    @property
+    def anonymous_session(self):
+        return True
+
+class ConnectionCubicWebRequestBase(_CubicWebRequestBase):
+    cnx = None
+    session = None
+
+    def __init__(self, vreg, https=False, form=None, headers={}):
+        """"""
+        self.vreg = vreg
+        try:
+            # no vreg or config which doesn't handle translations
+            self.translations = vreg.config.translations
+        except AttributeError:
+            self.translations = {}
+        super(ConnectionCubicWebRequestBase, self).__init__(vreg, https=https,
+                                                       form=form, headers=headers)
+        self.session = _MockAnonymousSession()
+        self.cnx = self.user = _NeedAuthAccessMock()
+
+    @property
+    def transaction_data(self):
+        return self.cnx.transaction_data
+
+    def set_cnx(self, cnx):
+        self.cnx = cnx
+        self.session = cnx.session
+        self._set_user(cnx.user)
+        self.set_user_language(cnx.user)
+
+    def execute(self, *args, **kwargs):
+        rset = self.cnx.execute(*args, **kwargs)
+        rset.req = self
+        return rset
+
+    def set_default_language(self, vreg):
+        try:
+            lang = vreg.property_value('ui.language')
+        except Exception: # property may not be registered
+            lang = 'en'
+        try:
+            self.set_language(lang)
+        except KeyError:
+            # this occurs usually during test execution
+            self._ = self.__ = text_type
+            self.pgettext = lambda x, y: text_type(y)
+
+    entity_metas = _cnx_func('entity_metas')
+    source_defs = _cnx_func('source_defs')
+    get_shared_data = _cnx_func('get_shared_data')
+    set_shared_data = _cnx_func('set_shared_data')
+    describe = _cnx_func('describe') # deprecated XXX
+
+    # security #################################################################
+
+    security_enabled = _cnx_func('security_enabled')
+
+    # server-side service call #################################################
+
+    def call_service(self, regid, **kwargs):
+        return self.cnx.call_service(regid, **kwargs)
+
+    # entities cache management ###############################################
+
+    def entity_cache(self, eid):
+        return self.transaction_data['req_ecache'][eid]
+
+    def set_entity_cache(self, entity):
+        ecache = self.transaction_data.setdefault('req_ecache', {})
+        ecache.setdefault(entity.eid, entity)
+
+    def cached_entities(self):
+        return self.transaction_data.get('req_ecache', {}).values()
+
+    def drop_entity_cache(self, eid=None):
+        if eid is None:
+            self.transaction_data.pop('req_ecache', None)
+        else:
+            del self.transaction_data['req_ecache'][eid]
+
+
+CubicWebRequestBase = ConnectionCubicWebRequestBase
+
+
+## HTTP-accept parsers / utilies ##############################################
+def _mimetype_sort_key(accept_info):
+    """accepted mimetypes must be sorted by :
+
+    1/ highest score first
+    2/ most specific mimetype first, e.g. :
+       - 'text/html level=1' is more specific 'text/html'
+       - 'text/html' is more specific than 'text/*'
+       - 'text/*' itself more specific than '*/*'
+
+    """
+    raw_value, (media_type, media_subtype, media_type_params), score = accept_info
+    # FIXME: handle '+' in media_subtype ? (should xhtml+xml have a
+    # higher precedence than xml ?)
+    if media_subtype == '*':
+        score -= 0.0001
+    if media_type == '*':
+        score -= 0.0001
+    return 1./score, media_type, media_subtype, 1./(1+len(media_type_params))
+
+def _charset_sort_key(accept_info):
+    """accepted mimetypes must be sorted by :
+
+    1/ highest score first
+    2/ most specific charset first, e.g. :
+       - 'utf-8' is more specific than '*'
+    """
+    raw_value, value, score = accept_info
+    if value == '*':
+        score -= 0.0001
+    return 1./score, value
+
+def _parse_accept_header(raw_header, value_parser=None, value_sort_key=None):
+    """returns an ordered list accepted types
+
+    :param value_parser: a function to parse a raw accept chunk. If None
+    is provided, the function defaults to identity. If a function is provided,
+    it must accept 2 parameters ``value`` and ``other_params``. ``value`` is
+    the value found before the first ';', `other_params` is a dictionary
+    built from all other chunks after this first ';'
+
+    :param value_sort_key: a key function to sort values found in the accept
+    header. This function will be passed a 3-tuple
+    (raw_value, parsed_value, score). If None is provided, the default
+    sort_key is 1./score
+
+    :return: a list of 3-tuple (raw_value, parsed_value, score),
+    ordered by score. ``parsed_value`` will be the return value of
+    ``value_parser(raw_value)``
+    """
+    if value_sort_key is None:
+        value_sort_key = lambda infos: 1./infos[-1]
+    values = []
+    for info in raw_header.split(','):
+        score = 1.0
+        other_params = {}
+        try:
+            value, infodef = info.split(';', 1)
+        except ValueError:
+            value = info
+        else:
+            for info in infodef.split(';'):
+                try:
+                    infokey, infoval = info.split('=')
+                    if infokey == 'q': # XXX 'level'
+                        score = float(infoval)
+                        continue
+                except ValueError:
+                    continue
+                other_params[infokey] = infoval
+        parsed_value = value_parser(value, other_params) if value_parser else value
+        values.append( (value.strip(), parsed_value, score) )
+    values.sort(key=value_sort_key)
+    return values
+
+
+def _mimetype_parser(value, other_params):
+    """return a 3-tuple
+    (type, subtype, type_params) corresponding to the mimetype definition
+    e.g. : for 'text/*', `mimetypeinfo` will be ('text', '*', {}), for
+    'text/html;level=1', `mimetypeinfo` will be ('text', '*', {'level': '1'})
+    """
+    try:
+        media_type, media_subtype = value.strip().split('/', 1)
+    except ValueError: # safety belt : '/' should always be present
+        media_type = value.strip()
+        media_subtype = '*'
+    return (media_type, media_subtype, other_params)
+
+
+ACCEPT_HEADER_PARSER = {
+    'accept': (_mimetype_parser, _mimetype_sort_key),
+    'accept-charset': (None, _charset_sort_key),
+    }
+
+from cubicweb import set_log_methods
+set_log_methods(_CubicWebRequestBase, LOGGER)