[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:
+215
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user