mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 12:32:01 +02:00
452 lines
18 KiB
Python
452 lines
18 KiB
Python
# Copyright 2018 ACSONE SA/NV
|
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
|
"""
|
|
|
|
REST Service Registy Builder
|
|
============================
|
|
|
|
Register available REST services at the build of a registry.
|
|
|
|
This code is inspired by ``odoo.addons.component.builder.ComponentBuilder``
|
|
|
|
"""
|
|
import inspect
|
|
import logging
|
|
|
|
from werkzeug.routing import Map, Rule
|
|
|
|
import odoo
|
|
from odoo import http, models
|
|
|
|
from odoo.addons.component.core import WorkContext
|
|
|
|
from .. import restapi
|
|
from ..components.service import BaseRestService
|
|
from ..controllers.main import _PseudoCollection
|
|
from ..core import (
|
|
RestServicesRegistry,
|
|
_rest_controllers_per_module,
|
|
_rest_services_databases,
|
|
_rest_services_routes,
|
|
)
|
|
from ..tools import ROUTING_DECORATOR_ATTR, _inspect_methods
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RestServiceRegistration(models.AbstractModel):
|
|
"""Register REST services into the REST services registry
|
|
|
|
This class allows us to hook the registration of the root urls of all
|
|
the REST controllers installed into the current database at the end of the
|
|
Odoo's registry loading, using ``_register_hook``. This method is called
|
|
after all modules are loaded, so we are sure that we only register REST
|
|
services installed into the current database.
|
|
|
|
"""
|
|
|
|
_name = "rest.service.registration"
|
|
_description = "REST Services Registration Model"
|
|
|
|
def _register_hook(self):
|
|
# This method is called by Odoo when the registry is built,
|
|
# so in case the registry is rebuilt (cache invalidation, ...),
|
|
# we have to to rebuild the registry. We use a new
|
|
# registry so we have an empty cache and we'll add services in it.
|
|
services_registry = self._init_global_registry()
|
|
self.build_registry(services_registry)
|
|
# we also have to remove the RestController from the
|
|
# controller_per_module registry since it's an abstract controller
|
|
controllers = http.Controller.children_classes["base_rest"]
|
|
controllers = [
|
|
cls for cls in controllers if "RestController" not in cls.__name__
|
|
]
|
|
http.Controller.children_classes["base_rest"] = controllers
|
|
# create the final controller providing the http routes for
|
|
# the services available into the current database
|
|
self._build_controllers_routes(services_registry)
|
|
|
|
def _build_controllers_routes(self, services_registry):
|
|
for controller_def in services_registry.values():
|
|
for service in self._get_services(controller_def["collection_name"]):
|
|
self._prepare_non_decorated_endpoints(service)
|
|
self._build_controller(service, controller_def)
|
|
|
|
def _prepare_non_decorated_endpoints(self, service):
|
|
# Autogenerate routing info where missing
|
|
RestApiMethodTransformer(service).fix()
|
|
|
|
def _build_controller(self, service, controller_def):
|
|
_logger.debug("Build service %s for controller_def %s", service, controller_def)
|
|
base_controller_cls = controller_def["controller_class"]
|
|
# build our new controller class
|
|
ctrl_cls = RestApiServiceControllerGenerator(
|
|
service, base_controller_cls
|
|
).generate()
|
|
|
|
# generate an addon name used to register our new controller for
|
|
# the current database
|
|
addon_name = base_controller_cls._module
|
|
identifier = "{}_{}_{}".format(
|
|
self.env.cr.dbname,
|
|
service._collection.replace(".", "_"),
|
|
service._usage.replace(".", "_"),
|
|
)
|
|
base_controller_cls._identifier = identifier
|
|
# put our new controller into the new addon module
|
|
ctrl_cls.__module__ = "odoo.addons.{}".format(addon_name)
|
|
|
|
self.env.registry._init_modules.add(addon_name)
|
|
|
|
# register our conroller into the list of available controllers
|
|
http.Controller.children_classes[addon_name].append(ctrl_cls)
|
|
self._apply_defaults_to_controller_routes(controller_class=ctrl_cls)
|
|
|
|
def _apply_defaults_to_controller_routes(self, controller_class):
|
|
"""
|
|
Apply default routes properties defined on the controller_class to
|
|
routes where properties are missing
|
|
Set the automatic auth on controller's routes.
|
|
|
|
During definition of new controller, the _default_auth should be
|
|
applied on every routes (cfr @route odoo's decorator).
|
|
This auth attribute should be applied only if the route doesn't already
|
|
define it.
|
|
:return:
|
|
"""
|
|
for _name, method in _inspect_methods(controller_class):
|
|
routing = getattr(method, ROUTING_DECORATOR_ATTR, None)
|
|
if not routing:
|
|
continue
|
|
self._apply_default_auth_if_not_set(controller_class, routing)
|
|
self._apply_default_if_not_set(controller_class, routing, "csrf")
|
|
self._apply_default_if_not_set(controller_class, routing, "save_session")
|
|
self._apply_default_cors_if_not_set(controller_class, routing)
|
|
|
|
def _apply_default_if_not_set(self, controller_class, routing, attr_name):
|
|
default_attr_name = "_default_" + attr_name
|
|
if hasattr(controller_class, default_attr_name) and attr_name not in routing:
|
|
routing[attr_name] = getattr(controller_class, default_attr_name)
|
|
|
|
def _apply_default_auth_if_not_set(self, controller_class, routing):
|
|
default_attr_name = "_default_auth"
|
|
default_auth = getattr(controller_class, default_attr_name, None)
|
|
if default_auth:
|
|
if "auth" in routing:
|
|
auth = routing["auth"]
|
|
if auth == "public_or_default":
|
|
alternative_auth = "public_or_" + default_auth
|
|
if getattr(
|
|
self.env["ir.http"], "_auth_method_%s" % alternative_auth, None
|
|
):
|
|
routing["auth"] = alternative_auth
|
|
else:
|
|
_logger.debug(
|
|
"No %s auth method available: Fallback on %s",
|
|
alternative_auth,
|
|
default_auth,
|
|
)
|
|
routing["auth"] = default_auth
|
|
else:
|
|
routing["auth"] = default_auth
|
|
|
|
def _apply_default_cors_if_not_set(self, controller_class, routing):
|
|
default_attr_name = "_default_cors"
|
|
if hasattr(controller_class, default_attr_name) and "cors" not in routing:
|
|
cors = getattr(controller_class, default_attr_name)
|
|
routing["cors"] = cors
|
|
if cors and "OPTIONS" not in routing.get("methods", ["OPTIONS"]):
|
|
# add http method 'OPTIONS' required by cors if the route is
|
|
# restricted to specific method
|
|
routing["methods"].append("OPTIONS")
|
|
|
|
def _get_services(self, collection_name):
|
|
collection = _PseudoCollection(collection_name, self.env)
|
|
work = WorkContext(
|
|
model_name="rest.service.registration", collection=collection
|
|
)
|
|
component_classes = work._lookup_components(usage=None, model_name=None)
|
|
# removes component without collection that are not a rest service
|
|
component_classes = [
|
|
c for c in component_classes if self._filter_service_component(c)
|
|
]
|
|
return [comp(work) for comp in component_classes]
|
|
|
|
@staticmethod
|
|
def _filter_service_component(comp):
|
|
return (
|
|
issubclass(comp, BaseRestService)
|
|
and comp._collection
|
|
and comp._usage
|
|
and getattr(comp, "_is_rest_service_component", True)
|
|
)
|
|
|
|
def build_registry(self, services_registry, states=None, exclude_addons=None):
|
|
if not states:
|
|
states = ("installed", "to upgrade")
|
|
# we load REST, controllers following the order of the 'addons'
|
|
# dependencies to ensure that controllers defined in a more
|
|
# specialized addon and overriding more generic one takes precedences
|
|
# on the generic one into the registry
|
|
graph = odoo.modules.graph.Graph()
|
|
graph.add_module(self.env.cr, "base")
|
|
|
|
query = "SELECT name " "FROM ir_module_module " "WHERE state IN %s "
|
|
params = [tuple(states)]
|
|
if exclude_addons:
|
|
query += " AND name NOT IN %s "
|
|
params.append(tuple(exclude_addons))
|
|
self.env.cr.execute(query, params)
|
|
|
|
module_list = [name for (name,) in self.env.cr.fetchall() if name not in graph]
|
|
graph.add_modules(self.env.cr, module_list)
|
|
|
|
for module in graph:
|
|
self.load_services(module.name, services_registry)
|
|
|
|
def load_services(self, module, services_registry):
|
|
controller_defs = _rest_controllers_per_module.get(module, [])
|
|
for controller_def in controller_defs:
|
|
root_path = controller_def["root_path"]
|
|
is_base_contoller = not getattr(
|
|
controller_def["controller_class"], "_generated", False
|
|
)
|
|
if is_base_contoller:
|
|
current_controller = (
|
|
services_registry[root_path]["controller_class"]
|
|
if root_path in services_registry
|
|
else None
|
|
)
|
|
services_registry[controller_def["root_path"]] = controller_def
|
|
self._register_rest_route(controller_def["root_path"])
|
|
if (
|
|
current_controller
|
|
and current_controller != controller_def["controller_class"]
|
|
):
|
|
_logger.error(
|
|
"Only one REST controller can be safely declared for root path %s\n "
|
|
"Registering controller %s\n "
|
|
"Registered controller%s\n",
|
|
root_path,
|
|
controller_def,
|
|
services_registry[controller_def["root_path"]],
|
|
)
|
|
|
|
def _init_global_registry(self):
|
|
services_registry = RestServicesRegistry()
|
|
_rest_services_databases[self.env.cr.dbname] = services_registry
|
|
return services_registry
|
|
|
|
def _register_rest_route(self, route_path):
|
|
"""Register given route path to be handles as RestRequest.
|
|
|
|
See base_rest.http.get_request.
|
|
"""
|
|
_rest_services_routes[self.env.cr.dbname].add(route_path)
|
|
|
|
|
|
class RestApiMethodTransformer(object):
|
|
"""Helper class to generate and apply the missing restapi.method decorator
|
|
to service's methods defined without decorator.
|
|
|
|
Before 10/12.0.3.0.0 methods exposed by a service was based on implicit
|
|
conventions. This transformer is used to keep this functionality by
|
|
generating and applying the missing decorators. As result all the methods
|
|
exposed are decorated and the processing can be based on these decorators.
|
|
"""
|
|
|
|
def __init__(self, service):
|
|
self._service = service
|
|
|
|
def fix(self):
|
|
methods_to_fix = []
|
|
for name, method in _inspect_methods(self._service.__class__):
|
|
if not self._is_public_api_method(name):
|
|
continue
|
|
if not hasattr(method, ROUTING_DECORATOR_ATTR):
|
|
methods_to_fix.append(method)
|
|
for method in methods_to_fix:
|
|
self._fix_method_decorator(method)
|
|
|
|
def _is_public_api_method(self, method_name):
|
|
if method_name.startswith("_"):
|
|
return False
|
|
if not hasattr(self._service, method_name):
|
|
return False
|
|
if hasattr(BaseRestService, method_name):
|
|
# exclude methods from base class
|
|
return False
|
|
return True
|
|
|
|
def _fix_method_decorator(self, method):
|
|
method_name = method.__name__
|
|
routes = self._method_to_routes(method)
|
|
input_param = self._method_to_input_param(method)
|
|
output_param = self._method_to_output_param(method)
|
|
decorated_method = restapi.method(
|
|
routes=routes, input_param=input_param, output_param=output_param
|
|
)(getattr(self._service.__class__, method_name))
|
|
setattr(self._service.__class__, method_name, decorated_method)
|
|
|
|
def _method_to_routes(self, method):
|
|
"""
|
|
Generate the restapi.method's routes
|
|
:param method:
|
|
:return: A list of routes used to get access to the method
|
|
"""
|
|
method_name = method.__name__
|
|
signature = inspect.signature(method)
|
|
id_in_path_required = "_id" in signature.parameters
|
|
path = "/{}".format(method_name)
|
|
if id_in_path_required:
|
|
path = "/<int:id>" + path
|
|
if method_name in ("get", "search"):
|
|
paths = [path]
|
|
path = "/"
|
|
if id_in_path_required:
|
|
path = "/<int:id>"
|
|
paths.append(path)
|
|
return [(paths, "GET")]
|
|
elif method_name == "delete":
|
|
routes = [(path, "POST")]
|
|
path = "/"
|
|
if id_in_path_required:
|
|
path = "/<int:id>"
|
|
routes.append((path, "DELETE"))
|
|
elif method_name == "update":
|
|
paths = [path]
|
|
path = "/"
|
|
if id_in_path_required:
|
|
path = "/<int:id>"
|
|
paths.append(path)
|
|
routes = [(paths, "POST"), (path, "PUT")]
|
|
elif method_name == "create":
|
|
paths = [path]
|
|
path = "/"
|
|
if id_in_path_required:
|
|
path = "/<int:id>"
|
|
paths.append(path)
|
|
routes = [(paths, "POST")]
|
|
else:
|
|
routes = [(path, "POST")]
|
|
|
|
return routes
|
|
|
|
def _method_to_param(self, validator_method_name, direction):
|
|
validator_component = self._service.component(usage="cerberus.validator")
|
|
if validator_component.has_validator_handler(
|
|
self._service, validator_method_name, direction
|
|
):
|
|
return restapi.CerberusValidator(schema=validator_method_name)
|
|
return None
|
|
|
|
def _method_to_input_param(self, method):
|
|
validator_method_name = "_validator_{}".format(method.__name__)
|
|
return self._method_to_param(validator_method_name, "input")
|
|
|
|
def _method_to_output_param(self, method):
|
|
validator_method_name = "_validator_return_{}".format(method.__name__)
|
|
return self._method_to_param(validator_method_name, "output")
|
|
|
|
|
|
class RestApiServiceControllerGenerator(object):
|
|
"""
|
|
An object helper used to generate the http.Controller required to serve
|
|
the method decorated with the `@restappi.method` decorator
|
|
"""
|
|
|
|
def __init__(self, service, base_controller):
|
|
self._service = service
|
|
self._service_name = service._usage
|
|
self._base_controller = base_controller
|
|
|
|
@property
|
|
def _new_cls_name(self):
|
|
controller_name = self._base_controller.__name__
|
|
return "{}{}".format(
|
|
controller_name, self._service._usage.title().replace(".", "_")
|
|
)
|
|
|
|
def generate(self):
|
|
"""
|
|
:return: A new controller child of base_controller defining the routes
|
|
required to serve the method of the services.
|
|
"""
|
|
controller = type(
|
|
self._new_cls_name, (self._base_controller,), self._generate_methods()
|
|
)
|
|
controller._generated = True
|
|
return controller
|
|
|
|
def _generate_methods(self):
|
|
"""Generate controller's methods and associated routes
|
|
|
|
This method inspect the service definition and generate the appropriate
|
|
methods and routing rules for all the methods decorated with @restappi.method
|
|
:return: A dictionary of method name : method
|
|
"""
|
|
methods = {}
|
|
_globals = {}
|
|
root_path = self._base_controller._root_path
|
|
path_sep = ""
|
|
if root_path[-1] != "/":
|
|
path_sep = "/"
|
|
root_path = "{}{}{}".format(root_path, path_sep, self._service._usage)
|
|
for name, method in _inspect_methods(self._service.__class__):
|
|
routing = getattr(method, ROUTING_DECORATOR_ATTR, None)
|
|
if routing is None:
|
|
continue
|
|
for routes, http_method in routing["routes"]:
|
|
method_name = "{}_{}".format(http_method.lower(), name)
|
|
default_route = routes[0]
|
|
rule = Rule(default_route)
|
|
Map(rules=[rule])
|
|
if rule.arguments:
|
|
method = METHOD_TMPL_WITH_ARGS.format(
|
|
method_name=method_name,
|
|
service_name=self._service_name,
|
|
service_method_name=name,
|
|
args=", ".join([c[1] for c in rule._trace if c[0]]),
|
|
)
|
|
else:
|
|
method = METHOD_TMPL.format(
|
|
method_name=method_name,
|
|
service_name=self._service_name,
|
|
service_method_name=name,
|
|
)
|
|
exec(method, _globals)
|
|
method_exec = _globals[method_name]
|
|
route_params = dict(
|
|
route=["{}{}".format(root_path, r) for r in routes],
|
|
methods=[http_method],
|
|
type="restapi",
|
|
)
|
|
for attr in {"auth", "cors", "csrf", "save_session"}:
|
|
if attr in routing:
|
|
route_params[attr] = routing[attr]
|
|
method_exec = http.route(**route_params)(method_exec)
|
|
methods[method_name] = method_exec
|
|
return methods
|
|
|
|
|
|
METHOD_TMPL = """
|
|
def {method_name}(self, collection=None, **kwargs):
|
|
return self._process_method(
|
|
"{service_name}",
|
|
"{service_method_name}",
|
|
collection=collection,
|
|
params=kwargs
|
|
)
|
|
"""
|
|
|
|
|
|
METHOD_TMPL_WITH_ARGS = """
|
|
def {method_name}(self, {args}, collection=None, **kwargs):
|
|
return self._process_method(
|
|
"{service_name}",
|
|
"{service_method_name}",
|
|
*[{args}],
|
|
collection=collection,
|
|
params=kwargs
|
|
)
|
|
"""
|