diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py index 37498f67..cf593b8b 100644 --- a/endpoint/tests/test_endpoint.py +++ b/endpoint/tests/test_endpoint.py @@ -172,7 +172,7 @@ def test_routing(self): }, ) # check prefix - type(endpoint)._endpoint_route_prefix = "/foo" + self.patch(type(endpoint), "_endpoint_route_prefix", "/foo") endpoint._compute_route() __, info, __ = endpoint._get_routing_info() self.assertEqual( @@ -187,7 +187,6 @@ def test_routing(self): "readonly": False, }, ) - type(endpoint)._endpoint_route_prefix = "" @mute_logger("odoo.modules.registry") def test_unlink(self): diff --git a/endpoint_route_handler/README.rst b/endpoint_route_handler/README.rst index ea5549a3..e1646d5c 100644 --- a/endpoint_route_handler/README.rst +++ b/endpoint_route_handler/README.rst @@ -37,6 +37,9 @@ controller routes on the fly. Can be used as a mixin or as a tool. +Rules can also provide a generator to yield many routes computed from +the code instead of a single handler (see Usage). + **Table of contents** .. contents:: @@ -124,6 +127,56 @@ controllers) will be updated. You can see a real life example on shopfloor.app model. +Generated routes +---------------- + +A rule can provide a ``generator`` instead of a ``handler``. The +generator is called when the routing map is built and yields any number +of ``(url, endpoint)`` pairs. This way, a single row in the +``endpoint_route`` table can serve many routes computed from the code +(e.g. all the methods exposed by a set of services) and new routes do +not require any update of the table. + +The preferred way is to point to a model method, which can be extended +via standard inheritance: + +:: + + def _prepare_endpoint_rules(self, options=None): + options = { + "generator": { + "model": self._name, + "res_id": self.id, + "method_name": "_generate_routes", + } + } + return super()._prepare_endpoint_rules(options=options) + + def _generate_routes(self, rule): + for name in ("foo", "bar"): + route = f"{rule.route}/{name}" + endpoint = make_endpoint( + MyController()._handle, + {"routes": [route], "methods": ["POST"], "auth": "user"}, + pargs=(self.id, name), + ) + yield route, endpoint + +The method is called with superuser rights as the routing map can be +built by any user. A python class can be used too, via +``klass_dotted_path``, like for handlers. + +Use ``utils.make_endpoint`` to build endpoints: it fills the routing +keys that Odoo requires. + +A generator must be fast, deterministic and must not write to the +database. If it fails, the error is logged and its routes are skipped: +the rest of the routing map is not affected. + +The routes of the rule are refreshed when the rule changes (the routing +map version is bumped) or when the code changes (as for any controller: +restart the server). + Known issues / Roadmap ====================== diff --git a/endpoint_route_handler/models/ir_http.py b/endpoint_route_handler/models/ir_http.py index 0ca6872b..6613e0ab 100644 --- a/endpoint_route_handler/models/ir_http.py +++ b/endpoint_route_handler/models/ir_http.py @@ -32,9 +32,17 @@ def _endpoint_routing_rules(self): e_registry = self._endpoint_route_registry() for endpoint_rule in e_registry.get_rules(): _logger.debug("LOADING %s", endpoint_rule) - endpoint = endpoint_rule.endpoint - for url in endpoint_rule.routing["routes"]: - yield (url, endpoint) + if not endpoint_rule.is_generator: + yield from endpoint_rule.iter_routing_rules(self.env) + continue + # A broken generator must not break the whole routing map. + # Collect its rules first, so that nothing is yielded on failure. + try: + rules = list(endpoint_rule.iter_routing_rules(self.env)) + except Exception: + _logger.exception("Routes generation failed for %s", endpoint_rule) + continue + yield from rules @tools.ormcache("key", "self._endpoint_route_last_version()", cache="routing") def routing_map(self, key=None): diff --git a/endpoint_route_handler/readme/DESCRIPTION.md b/endpoint_route_handler/readme/DESCRIPTION.md index 272a8384..186df364 100644 --- a/endpoint_route_handler/readme/DESCRIPTION.md +++ b/endpoint_route_handler/readme/DESCRIPTION.md @@ -2,3 +2,6 @@ Technical module that provides a base handler for adding and removing controller routes on the fly. Can be used as a mixin or as a tool. + +Rules can also provide a generator to yield many routes +computed from the code instead of a single handler (see Usage). diff --git a/endpoint_route_handler/readme/USAGE.md b/endpoint_route_handler/readme/USAGE.md index 11a9a038..5c7fdf8f 100644 --- a/endpoint_route_handler/readme/USAGE.md +++ b/endpoint_route_handler/readme/USAGE.md @@ -66,3 +66,52 @@ existing one is updated, the ir.http.routing_map (which holds all Odoo controllers) will be updated. You can see a real life example on shopfloor.app model. + +## Generated routes + +A rule can provide a `generator` instead of a `handler`. +The generator is called when the routing map is built +and yields any number of `(url, endpoint)` pairs. +This way, a single row in the `endpoint_route` table +can serve many routes computed from the code +(e.g. all the methods exposed by a set of services) +and new routes do not require any update of the table. + +The preferred way is to point to a model method, +which can be extended via standard inheritance: + + def _prepare_endpoint_rules(self, options=None): + options = { + "generator": { + "model": self._name, + "res_id": self.id, + "method_name": "_generate_routes", + } + } + return super()._prepare_endpoint_rules(options=options) + + def _generate_routes(self, rule): + for name in ("foo", "bar"): + route = f"{rule.route}/{name}" + endpoint = make_endpoint( + MyController()._handle, + {"routes": [route], "methods": ["POST"], "auth": "user"}, + pargs=(self.id, name), + ) + yield route, endpoint + +The method is called with superuser rights +as the routing map can be built by any user. +A python class can be used too, via `klass_dotted_path`, +like for handlers. + +Use `utils.make_endpoint` to build endpoints: +it fills the routing keys that Odoo requires. + +A generator must be fast, deterministic and must not write to the database. +If it fails, the error is logged and its routes are skipped: +the rest of the routing map is not affected. + +The routes of the rule are refreshed when the rule changes +(the routing map version is bumped) +or when the code changes (as for any controller: restart the server). diff --git a/endpoint_route_handler/registry.py b/endpoint_route_handler/registry.py index 34444a03..4b470486 100644 --- a/endpoint_route_handler/registry.py +++ b/endpoint_route_handler/registry.py @@ -3,7 +3,6 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import functools -import importlib import json import logging @@ -17,6 +16,7 @@ from odoo.addons.base.models.ir_model import query_insert from .exceptions import EndpointHandlerNotFound +from .utils import import_method _logger = logging.getLogger(__name__) @@ -239,6 +239,9 @@ def update_rules(self, rules, init=False): return res def drop_rules(self, keys): + """Delete rules by key. Nothing to do w/o keys (`IN ()` is invalid SQL).""" + if not keys: + return True self.cr.execute("DELETE FROM endpoint_route WHERE key IN %s", (tuple(keys),)) return True @@ -315,10 +318,19 @@ def options(self): def options(self, value): """Validate options. - See `_get_handler` for more info. + Options must contain either a `handler` or a `generator` key. + + See `_get_handler` and `_get_generator` for more info. """ - assert "klass_dotted_path" in value["handler"] - assert "method_name" in value["handler"] + handler = value.get("handler") + generator = value.get("generator") + assert bool(handler) != bool(generator), "Provide `handler` or `generator`" + if handler: + assert "klass_dotted_path" in handler + assert "method_name" in handler + else: + assert "method_name" in generator + assert "klass_dotted_path" in generator or "model" in generator self.opts = value @classmethod @@ -348,9 +360,18 @@ def to_row(self): row[k] = json.dumps(v) return row + @property + def is_generator(self): + """True if the rule yields its routes via a generator.""" + return bool(self.opts.get("generator")) + @property def endpoint(self): """Lookup http.Endpoint to be used for the routing map.""" + if self.is_generator: + raise ValueError( + f"{self} is a generator rule: use `iter_routing_rules` instead" + ) handler = self._get_handler() pargs = self.handler_options.get("default_pargs", ()) kwargs = self.handler_options.get("default_kwargs", {}) @@ -363,6 +384,28 @@ def endpoint(self): def handler_options(self): return self.options.handler + @property + def generator_options(self): + """Options of the generator (see `_get_generator`).""" + return self.options.generator + + def iter_routing_rules(self, env): + """Yield `(url, endpoint)` pairs to load in the routing map. + + A handler rule yields its own endpoint for each route. + A generator rule delegates to its generator, + which can yield any number of routes. + """ + if not self.is_generator: + endpoint = self.endpoint + for url in self.routing["routes"]: + yield (url, endpoint) + return + generator = self._get_generator(env) + pargs = self.generator_options.get("default_pargs", ()) + kwargs = self.generator_options.get("default_kwargs", {}) + yield from generator(self, *pargs, **kwargs) + def _get_handler(self): """Resolve endpoint handler lookup. @@ -379,20 +422,42 @@ def _get_handler(self): If any of them is not found, a specific exception is raised. """ - mod_path, klass_name = self.handler_options.klass_dotted_path.rsplit(".", 1) - try: - mod = importlib.import_module(mod_path) - except ImportError as exc: - raise EndpointHandlerNotFound(f"Module `{mod_path}` not found") from exc - try: - klass = getattr(mod, klass_name) - except AttributeError as exc: - raise EndpointHandlerNotFound(f"Class `{klass_name}` not found") from exc - method_name = self.handler_options.method_name + return import_method( + self.handler_options.klass_dotted_path, self.handler_options.method_name + ) + + def _get_generator(self, env): + """Resolve routes generator lookup. + + `options` must contain `generator` key to provide either: + + * `model`, optional `res_id` and `method_name`: + the method of the model (or of the record) is used. + This is the preferred way as the generator + can be extended via standard Odoo inheritance. + * `klass_dotted_path` and `method_name`: + same lookup as for handlers. + + The generator is called with the rule as first argument + followed by `default_pargs` and `default_kwargs`. + It must return an iterable of `(url, endpoint)`. + Use `utils.make_endpoint` to build valid endpoints. + """ + opts = self.generator_options + if not opts.get("model"): + return import_method(opts.klass_dotted_path, opts.method_name) + if opts.model not in env: + raise EndpointHandlerNotFound(f"Model `{opts.model}` not found") + target = env[opts.model].sudo() + if opts.get("res_id"): + target = target.browse(opts.res_id).exists() + if not target: + raise EndpointHandlerNotFound( + f"Record `{opts.model}({opts.res_id})` not found" + ) try: - method = getattr(klass(), method_name) + return getattr(target, opts.method_name) except AttributeError as exc: raise EndpointHandlerNotFound( - f"Method name `{method_name}` not found" + f"Method name `{opts.method_name}` not found" ) from exc - return method diff --git a/endpoint_route_handler/static/description/index.html b/endpoint_route_handler/static/description/index.html index b4f2315e..969b6b5e 100644 --- a/endpoint_route_handler/static/description/index.html +++ b/endpoint_route_handler/static/description/index.html @@ -3,7 +3,7 @@ -Endpoint route handler +README.rst