[REF] core: HTTPocalypse (4) route binding

This commit is the 4th commit of a comprehensive refactor of our HTTP
framework. See odoo/odoo#78857 for complete historic, discussions and
rationnals.

Complete refactor of the "registry" of controllers. The `ControllerType`
metaclass have been replaced by an abstract class with a py3.7
`__init_subclass__`. The `Endpoint` class is gone too, replaced by a
clever usage of `functools.partial`. This refactor is "pure", it doesn't
add any new feature, it only merely adds a few warnings.

The four `@route`, `Controller`, `_generate_routing_rule`, `routing_map`
work as follow:

1. A reference to each immediate child class of `Controller` (not grand-
children) is registered in a global list indexed by module (thus a
dictionnary) everytime the server starts. Remember that every first-
child (not grand-children) of `Controller` is the primary controller,
the one that can later be extended by other controllers (the grand-
children) in other modules. Remember that it is possible to get each
class's children via the `__subclasses__` dunder method.

2. Every controller method that is decorated with `@route` is granted an
attribute: `original_routing`, a dictionnary containing the `@route`
arguments. When a controller method has the `original_routing`
attribute, this method is called an `endpoint`.

3. `_generate_routing_rules` receives the list of installed module
names, this list is topologicaly sorted according to the modules
dependencies. That is `base` comes before `web` in this list. The
objectif of this function is to pair each route to an endpoint whoose
class's MRO respect the above-mentioned order. Whe achieve this by
carefully crafting classes at runtime, classes inheritating from the
correct "source code" controllers in accordance to the topology. This
method is also responsible of merging each method's `original_routing`
into one `routing` dictionnary, the very `rule.endpoint.routing` dict
that is used through the rest of the http framework.

4. Each route-endpoint pair is saved into a `routing_map`, an object
that bind each route to its endpoint. This object exposes a `match`
method used to find back the endpoint given its route (=http path).

In addition to this refactor, we added some new helpers in our tools,
among them `submap` that implement a kind of `dict() - set() -> dict()`
operator. Filtering a dict on a set of keys is a common operation but
the python standard library lacks a dedicated operator.

PR: odoo#78857
Task: 2571224
This commit is contained in:
Julien Castiaux
2022-02-24 13:30:48 +00:00
parent 77df31f67a
commit 347a3ccf76
3 changed files with 251 additions and 4 deletions
+215 -1
View File
@@ -183,6 +183,15 @@ babel.core.LOCALE_ALIASES['nb'] = 'nb_NO'
...
# The @route arguments to propagate from the decorated method to the
# routing rule.
ROUTING_KEYS = {
'defaults', 'subdomain', 'build_only', 'strict_slashes', 'redirect_to',
'alias', 'host', 'methods',
}
...
# The cache duration for static content from the filesystem, one week.
STATIC_CACHE = 60 * 60 * 24 * 7
@@ -287,14 +296,110 @@ class Controller:
"""
Class mixin that provide module controllers the ability to serve
content over http and to be extended in child modules.
Each class :ref:`inheriting <python:tut-inheritance>` from
:class:`~odoo.http.Controller` can use the :func:`~odoo.http.route`:
decorator to route matching incoming web requests to decorated
methods.
Like models, controllers can be extended by other modules. The
extension mechanism is different because controllers can work in a
database-free environment and therefore cannot use
:class:~odoo.api.Registry:.
To *override* a controller, :ref:`inherit <python:tut-inheritance>`
from its class, override relevant methods and re-expose them with
:func:`~odoo.http.route`:. Please note that the decorators of all
methods are combined, if the overriding method’s decorator has no
argument all previous ones will be kept, any provided argument will
override previously defined ones.
.. code-block:
class GreetingController(odoo.http.Controller):
@route('/greet', type='http', auth='public')
def greeting(self):
return 'Hello'
class UserGreetingController(GreetingController):
@route(auth='user') # override auth, keep path and type
def greeting(self):
return super().handler()
"""
children_classes = collections.defaultdict(list) # indexed by module
@classmethod
def __init_subclass__(cls):
super().__init_subclass__()
if Controller in cls.__bases__:
path = cls.__module__.split('.')
module = path[2] if path[:2] == ['odoo', 'addons'] else ''
Controller.children_classes[module].append(cls)
def route(route=None, **routing):
"""
Decorate a controller method in order to route incoming requests
matching the given URL and options to the decorated method.
.. warning::
It is mandatory to re-decorate any method that is overridden in
controller extensions but the arguments can be omitted. See
:class:`~odoo.http.Controller` for more details.
:param Union[str, Iterable[str]] route: The paths that the decorated
method is serving. Incoming HTTP request paths matching this
route will be routed to this decorated method. See `werkzeug
routing documentation <http://werkzeug.pocoo.org/docs/routing/>`_
for the format of route expressions.
:param str type: The type of request, either ``'json'`` or
``'http'``. It describes where to find the request parameters
and how to serialize the response.
:param str auth: The authentication method, one of the following:
* ``'user'``: The user must be authenticated and the current
request will be executed using the rights of the user.
* ``'public'``: The user may or may not be authenticated. If he
isn't, the current request will be executed using the shared
Public user.
* ``'none'``: The method is always active, even if there is no
database. Mainly used by the framework and authentication
modules. There request code will not have any facilities to
access the current user.
:param Iterable[str] methods: A list of http methods (verbs) this
route applies to. If not specified, all methods are allowed.
:param str cors: The Access-Control-Allow-Origin cors directive value.
:param bool csrf: Whether CSRF protection should be enabled for the
route. Enabled by default for ``'http'``-type requests, disabled
by default for ``'json'``-type requests. See
:ref:`CSRF Protection <csrf>` for more.
"""
def decorator(endpoint):
fname = f"<function {endpoint.__module__}.{endpoint.__name__}>"
# Sanitize the routing
assert routing.get('type', 'http') in _dispatchers.keys()
if route:
routing['routes'] = route if isinstance(route, list) else [route]
wrong = routing.pop('method', None)
if wrong is not None:
_logger.warning("%s defined with invalid routing parameter 'method', assuming 'methods'", fname)
routing['methods'] = wrong
@functools.wraps(endpoint)
def route_wrapper(self, *args, **params):
params_ok = filter_kwargs(endpoint, params)
params_ko = set(params) - set(params_ok)
if params_ko:
_logger.warning("%s called ignoring args %s", fname, params_ko)
result = endpoint(self, *args, **params_ok)
...
return result
route_wrapper.original_routing = routing
route_wrapper.original_endpoint = endpoint
return route_wrapper
return decorator
def _generate_routing_rules(modules, nodb_only, converters=None):
"""
@@ -304,7 +409,98 @@ def _generate_routing_rules(modules, nodb_only, converters=None):
arguments of said method with the @route arguments of the method it
overrides.
"""
...
def is_valid(cls):
""" Determine if the class is defined in an addon. """
path = cls.__module__.split('.')
return path[:2] == ['odoo', 'addons'] and path[2] in modules
def get_leaf_classes(cls):
"""
Find the classes that have no child and that have ``cls`` as
ancestor.
"""
result = []
for subcls in cls.__subclasses__():
if is_valid(subcls):
result.extend(get_leaf_classes(subcls))
if not result and is_valid(cls):
result.append(cls)
return result
def build_controllers():
"""
Create dummy controllers that inherit only from the controllers
defined at the given ``modules`` (often system wide modules or
installed modules). Modules in this context are Odoo addons.
"""
highest_controllers = []
for module in modules:
highest_controllers.extend(Controller.children_classes.get(module, []))
for top_ctrl in highest_controllers:
leaf_controllers = list(unique(get_leaf_classes(top_ctrl)))
name = '{} (extended by {})'.format(
top_ctrl.__name__,
', '.join(bot_ctrl.__name__ for bot_ctrl in leaf_controllers),
)
Ctrl = type(name, tuple(reversed(leaf_controllers)), {})
yield Ctrl()
for ctrl in build_controllers():
for method_name, method in inspect.getmembers(ctrl, inspect.ismethod):
# Skip this method if it is not @route decorated anywhere in
# the hierarchy
def is_method_a_route(cls):
return resolve_attr(cls, f'{method_name}.original_routing', None) is not None
if not any(map(is_method_a_route, type(ctrl).mro())):
continue
merged_routing = {
# 'type': 'http', # set below
'auth': 'user',
'methods': None,
'routes': [],
'readonly': False,
}
for cls in unique(reversed(type(ctrl).mro())): # ancestors first
submethod = getattr(cls, method_name, None)
if submethod is None:
continue
if not hasattr(submethod, 'original_routing'):
_logger.warning("The endpoint %s is not decorated by @route(), decorating it myself.", f'{cls.__module__}.{cls.__name__}.{method_name}')
submethod = route()(submethod)
# Ensure "type" is defined on each method's own routing,
# also ensure overrides don't change the routing type.
default_type = submethod.original_routing.get('type', 'http')
routing_type = merged_routing.setdefault('type', default_type)
if submethod.original_routing.get('type') not in (None, routing_type):
_logger.warning("The endpoint %s changes the route type, using the original type: %r.", f'{cls.__module__}.{cls.__name__}.{method_name}', routing_type)
submethod.original_routing['type'] = routing_type
merged_routing.update(submethod.original_routing)
if not merged_routing['routes']:
_logger.warning("%s is a controller endpoint without any route, skipping.", f'{cls.__module__}.{cls.__name__}.{method_name}')
continue
if nodb_only and merged_routing['auth'] != "none":
continue
for url in merged_routing['routes']:
# duplicates the function (partial) with a copy of the
# original __dict__ (update_wrapper) to keep a reference
# to `original_routing` and `original_endpoint`, assign
# the merged routing ONLY on the duplicated function to
# ensure method's immutability.
endpoint = functools.partial(method)
functools.update_wrapper(endpoint, method)
endpoint.routing = merged_routing
yield (url, endpoint)
# =========================================================
@@ -442,6 +638,24 @@ class Application:
mod2path[module] = static_path
return mod2path
@lazy_property
def nodb_routing_map(self):
nodb_routing_map = werkzeug.routing.Map(strict_slashes=False, converters=None)
for url, endpoint in _generate_routing_rules([''] + odoo.conf.server_wide_modules, nodb_only=True):
routing = submap(endpoint.routing, ROUTING_KEYS)
if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
routing['methods'] = routing['methods'] + ['OPTIONS']
rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing)
rule.merge_slashes = False
nodb_routing_map.add(rule)
return nodb_routing_map
def get_db_router(self, db):
if not db:
return self.nodb_routing_map
return request.registry['ir.http'].routing_map()
def __call__(self, environ, start_response):
"""
WSGI application entry point.
+18 -1
View File
@@ -4,8 +4,8 @@
__all__ = ['synchronized', 'lazy_classproperty', 'lazy_property',
'classproperty', 'conditional', 'lazy']
from inspect import getsourcefile, Parameter, signature
from functools import wraps
from inspect import getsourcefile
from json import JSONEncoder
from decorator import decorator
@@ -62,6 +62,23 @@ def conditional(condition, decorator):
else:
return lambda fn: fn
def filter_kwargs(func, kwargs):
""" Filter the given keyword arguments to only return the kwargs
that binds to the function's signature.
"""
leftovers = set(kwargs)
for p in signature(func).parameters.values():
if p.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY):
leftovers.discard(p.name)
elif p.kind == Parameter.VAR_KEYWORD: # **kwargs
leftovers.clear()
break
if not leftovers:
return kwargs
return {key: kwargs[key] for key in kwargs if key not in leftovers}
def synchronized(lock_attr='_lock'):
@decorator
def locked(func, inst, *args, **kwargs):
+18 -2
View File
@@ -666,9 +666,14 @@ def split_every(n, iterable, piece_maker=tuple):
piece = piece_maker(islice(iterator, n))
# port of python 2.6's attrgetter with support for dotted notation
def resolve_attr(obj, attr):
raise_error = object() # sentinel
def resolve_attr(obj, attr, default=raise_error):
for name in attr.split("."):
obj = getattr(obj, name)
obj = getattr(obj, name, default)
if obj is raise_error:
raise AttributeError(f"'{obj}' object has no attribute '{name}'")
if obj == default:
break
return obj
def attrgetter(*items):
@@ -1218,6 +1223,17 @@ def unique(it):
seen.add(e)
yield e
def submap(mapping, keys):
"""
Get a filtered copy of the mapping where only some keys are present.
:param Mapping mapping: the original dict-like structure to filter
:param Iterable keys: the list of keys to keep
:return dict: a filtered dict copy of the original mapping
"""
keys = frozenset(keys)
return {key: mapping[key] for key in mapping if key in keys}
class Reverse(object):
""" Wraps a value and reverses its ordering, useful in key functions when
mixing ascending and descending sort on non-numeric data as the