From 748dc55763e839271d2fad269f606ad9498adc83 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 9 Oct 2026 22:30:33 +0200 Subject: [PATCH] [IMP] endpoint_route_handler: support routes generators A rule can now 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 allows to register a single row for many routes computed from the code (eg: all the endpoints exposed by a set of services) without having to update the endpoint_route table every time a new route is added. Generators can be model methods (preferred, extensible via inheritance) or python classes (same lookup as handlers). A failing generator is logged and skipped so that it does not break the whole routing map. Add `utils.make_endpoint` helper to build endpoints with the routing keys required by Odoo. Based on #170 (routing map w/o bound request). --- endpoint_route_handler/models/ir_http.py | 14 +- endpoint_route_handler/readme/DESCRIPTION.md | 3 + endpoint_route_handler/readme/USAGE.md | 49 +++++ endpoint_route_handler/registry.py | 99 +++++++-- endpoint_route_handler/tests/__init__.py | 1 + .../tests/fake_controllers.py | 28 +++ .../tests/test_generator.py | 194 ++++++++++++++++++ endpoint_route_handler/utils.py | 60 ++++++ 8 files changed, 428 insertions(+), 20 deletions(-) create mode 100644 endpoint_route_handler/tests/test_generator.py create mode 100644 endpoint_route_handler/utils.py diff --git a/endpoint_route_handler/models/ir_http.py b/endpoint_route_handler/models/ir_http.py index d249a3d3..edfa051c 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", "cls._endpoint_route_last_version()", cache="routing") def routing_map(cls, 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 bd5922ad..ef7e64ce 100644 --- a/endpoint_route_handler/readme/USAGE.md +++ b/endpoint_route_handler/readme/USAGE.md @@ -59,3 +59,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 50c0c86c..601d31e7 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__) @@ -238,6 +238,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 @@ -314,10 +317,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 @@ -347,9 +359,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", {}) @@ -362,6 +383,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. @@ -378,20 +421,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/tests/__init__.py b/endpoint_route_handler/tests/__init__.py index e1ad88a9..d3f62e7b 100644 --- a/endpoint_route_handler/tests/__init__.py +++ b/endpoint_route_handler/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_registry from . import test_endpoint from . import test_endpoint_controller +from . import test_generator diff --git a/endpoint_route_handler/tests/fake_controllers.py b/endpoint_route_handler/tests/fake_controllers.py index 1ad001ca..fdeeb3e3 100644 --- a/endpoint_route_handler/tests/fake_controllers.py +++ b/endpoint_route_handler/tests/fake_controllers.py @@ -4,6 +4,8 @@ from odoo import http +from ..utils import make_endpoint + class CTRLFake(http.Controller): # Shortcut for dotted path @@ -29,3 +31,29 @@ def _do_something1(self, foo=None): def _do_something2(self, default_arg, foo=None): body = f"{default_arg} -> got: {foo}" return http.request.make_response(body) + + +class GeneratorFake: + """Fake routes generator.""" + + _path = "odoo.addons.endpoint_route_handler.tests.fake_controllers.GeneratorFake" + + def generate(self, rule, *names, methods=("GET",)): + """Yield `/` for each name, served by `handle(name)`.""" + for name in names: + route = f"{rule.route}/{name}" + routing = { + "routes": [route], + "auth": "public", + "methods": list(methods), + "csrf": False, + } + yield route, make_endpoint(self.handle, routing, pargs=(name,)) + + def handle(self, name, **kw): + """Respond w/ the name the route was generated for.""" + return http.request.make_response(f"Generated: {name}") + + def broken(self, rule, *names): + """Fail as a buggy generator would do.""" + raise ValueError("Boom") diff --git a/endpoint_route_handler/tests/test_generator.py b/endpoint_route_handler/tests/test_generator.py new file mode 100644 index 00000000..b91af1c2 --- /dev/null +++ b/endpoint_route_handler/tests/test_generator.py @@ -0,0 +1,194 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from unittest import mock + +from odoo.tests.common import HttpCase, TransactionCase, tagged +from odoo.tools import mute_logger + +from odoo.addons.endpoint_route_handler.exceptions import EndpointHandlerNotFound +from odoo.addons.endpoint_route_handler.registry import EndpointRegistry +from odoo.addons.endpoint_route_handler.utils import make_endpoint + +from .fake_controllers import CTRLFake, GeneratorFake + +GROUP = "test_route_generator" + + +def _generator_options(method_name="generate", names=(), **kw): + """Options of a rule using `GeneratorFake.` w/ `names` as args.""" + opts = { + "klass_dotted_path": GeneratorFake._path, + "method_name": method_name, + "default_pargs": list(names), + } + opts.update(kw) + return {"generator": opts} + + +class GeneratorMixin: + @classmethod + def _make_generator_rule(cls, key="gen1", route="/gen", names=(), options=None): + """Make a rule (not stored) whose generator yields `/` per name.""" + return cls.reg.make_rule( + key, + route, + options or _generator_options(names=names), + # The routing of the anchor rule is not loaded as is, + # but generators can use it as base. + {"routes": [route], "type": "http", "auth": "public"}, + f"hash-{key}", + route_group=GROUP, + ) + + +@tagged("-at_install", "post_install") +class TestGenerator(TransactionCase, GeneratorMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.reg = EndpointRegistry.registry_for(cls.env.cr) + + def test_options_validation(self): + """A rule has either a handler or a generator, and a valid generator.""" + handler = {"klass_dotted_path": CTRLFake._path, "method_name": "handler1"} + # Both handler and generator + with self.assertRaises(AssertionError): + self._make_generator_rule( + options=dict(_generator_options(), handler=handler) + ) + # Generator w/o class nor model + with self.assertRaises(AssertionError): + self._make_generator_rule(options={"generator": {"method_name": "x"}}) + rule = self._make_generator_rule() + self.assertTrue(rule.is_generator) + + def test_drop_no_rules(self): + """Dropping no rules is a no-op (no invalid SQL).""" + self.assertTrue(self.reg.drop_rules([])) + + def test_make_endpoint(self): + """Endpoints get the routing keys required by Odoo; invalid routing raises.""" + # `handler1(arg1, arg2=2)`: `pargs` are bound to the endpoint + endpoint = make_endpoint( + CTRLFake().handler1, {"routes": ["/foo"]}, pargs=("one",) + ) + self.assertEqual(endpoint(), ("one", 2)) + # Missing keys are filled w/ defaults + self.assertEqual(endpoint.routing["type"], "http") + self.assertEqual(endpoint.routing["auth"], "user") + self.assertIs(endpoint.routing["readonly"], False) + self.assertIsNone(endpoint.routing["methods"]) + with self.assertRaisesRegex(ValueError, "No route"): + make_endpoint(CTRLFake().handler1, {"routes": []}) + with self.assertRaisesRegex(ValueError, "Unknown routing type"): + make_endpoint(CTRLFake().handler1, {"routes": ["/a"], "type": "nope"}) + + def test_generator_python(self): + """A class generator yields its routes; the rule has no endpoint itself.""" + rule = self._make_generator_rule(route="/gen", names=("one", "two")) + with self.assertRaises(ValueError): + rule.endpoint # pylint: disable=pointless-statement # noqa: B018 + res = list(rule.iter_routing_rules(self.env)) + self.assertEqual([url for url, __ in res], ["/gen/one", "/gen/two"]) + __, endpoint = res[0] + # Routing set by `GeneratorFake.generate` + self.assertEqual(endpoint.routing["methods"], ["GET"]) + self.assertEqual(endpoint.routing["routes"], ["/gen/one"]) + + def test_generator_model(self): + """A model generator is called on the record, as sudo, w/ the rule args.""" + users_cls = type(self.env["res.users"]) + calls = [] + + def _fake_generate(records, rule, *names): + calls.append((records, names)) + yield from GeneratorFake().generate(rule, *names) + + # Generator = `res.users()._test_generate_routes("three")` + options = { + "generator": { + "model": "res.users", + "res_id": self.env.user.id, + "method_name": "_test_generate_routes", + "default_pargs": ["three"], + } + } + rule = self._make_generator_rule(route="/gen", options=options) + # Add the generator method on the model + with mock.patch.object( + users_cls, "_test_generate_routes", _fake_generate, create=True + ): + res = list(rule.iter_routing_rules(self.env)) + self.assertEqual([url for url, __ in res], ["/gen/three"]) + records, names = calls[0] + self.assertEqual(records, self.env.user) + self.assertTrue(records.env.su) + self.assertEqual(names, ("three",)) + + def test_generator_model_not_found(self): + """A missing generator model or method raises EndpointHandlerNotFound.""" + options = {"generator": {"model": "nope.nope", "method_name": "x"}} + rule = self._make_generator_rule(options=options) + with self.assertRaises(EndpointHandlerNotFound): + list(rule.iter_routing_rules(self.env)) + options = {"generator": {"model": "res.users", "method_name": "_nope_"}} + rule = self._make_generator_rule(options=options) + with self.assertRaises(EndpointHandlerNotFound): + list(rule.iter_routing_rules(self.env)) + + def test_routing_rules_skip_broken_generator(self): + """A failing generator is skipped, the others are loaded.""" + EndpointRegistry.wipe_registry_for(self.env.cr) + good = self._make_generator_rule("gen1", "/gen", names=("one", "two")) + # `GeneratorFake.broken` raises + broken = self._make_generator_rule( + "gen2", "/broken", options=_generator_options("broken") + ) + self.reg.update_rules([good, broken]) + with mute_logger("odoo.addons.endpoint_route_handler.models.ir_http"): + urls = [url for url, __ in self.env["ir.http"]._endpoint_routing_rules()] + self.assertIn("/gen/one", urls) + self.assertIn("/gen/two", urls) + self.assertFalse([x for x in urls if x.startswith("/broken")]) + + +@tagged("-at_install", "post_install") +class TestGeneratorHttp(HttpCase, GeneratorMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.reg = EndpointRegistry.registry_for(cls.env.cr) + # Serves GET `/test_generator/one` and `/test_generator/two` + rule = cls._make_generator_rule(route="/test_generator", names=("one", "two")) + cls.reg.update_rules([rule]) + cls.env.registry.clear_cache("routing") + + def test_generated_routes_served(self): + """Generated routes are served by the generated endpoint.""" + for name in ("one", "two"): + response = self.url_open(f"/test_generator/{name}") + self.assertEqual(response.status_code, 200) + # Response of `GeneratorFake.handle(name)` + self.assertEqual(response.text, f"Generated: {name}") + + def test_generated_routes_method(self): + """Generated routes only accept their methods (GET here).""" + # `data` makes it a POST + response = self.url_open("/test_generator/one", data={"a": 1}) + self.assertEqual(response.status_code, 405) + + def test_anchor_route_not_served(self): + """The route of the rule itself (the anchor) is not served.""" + response = self.url_open("/test_generator") + self.assertEqual(response.status_code, 404) + + def test_routing_map_wo_request(self): + """Generated routes are loaded in a routing map built w/o a request.""" + self.reg.update_rules( + [self._make_generator_rule("gen2", "/gen", names=("one",))] + ) + self.env.registry.clear_cache("routing") + urls = [r.rule for r in self.env["ir.http"].routing_map().iter_rules()] + self.assertIn("/gen/one", urls) diff --git a/endpoint_route_handler/utils.py b/endpoint_route_handler/utils.py new file mode 100644 index 00000000..589cca95 --- /dev/null +++ b/endpoint_route_handler/utils.py @@ -0,0 +1,60 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""Helpers to build endpoints for the routing map.""" + +import functools +import importlib + +from odoo import http + +from .exceptions import EndpointHandlerNotFound + +# Routing keys read by Odoo without a default value +# (see `odoo.http.Request._serve_db` and `ir.http._authenticate`). +# Endpoints generated outside of the standard controller machinery +# must provide them. +ROUTING_DEFAULTS = { + "type": "http", + "auth": "user", + "methods": None, + "readonly": False, +} + + +def make_endpoint(func, routing, pargs=(), kwargs=None): + """Build an endpoint ready to be loaded in the routing map. + + :param func: callable handling the request + :param routing: routing information (must contain ``routes``), + missing mandatory keys are filled with ``ROUTING_DEFAULTS`` + :param pargs: positional arguments bound to ``func`` + :param kwargs: keyword arguments bound to ``func`` + :return: a ``functools.partial`` carrying the ``routing`` attribute + """ + endpoint = functools.partial(func, *pargs, **(kwargs or {})) + functools.update_wrapper(endpoint, func) + routing = dict(ROUTING_DEFAULTS, **routing) + if not routing.get("routes"): + raise ValueError(f"No route defined for {func}") + if routing["type"] not in http._dispatchers: + raise ValueError(f"Unknown routing type `{routing['type']}` for {func}") + endpoint.routing = routing + return endpoint + + +def import_method(klass_dotted_path, method_name): + """Import the class from its dotted path and return the bound method.""" + mod_path, klass_name = 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 + try: + return getattr(klass(), method_name) + except AttributeError as exc: + raise EndpointHandlerNotFound(f"Method name `{method_name}` not found") from exc