From 9e17a44b78d9f01a92f2c125663f5b0536596d5f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2026 16:11:27 +0200 Subject: [PATCH 1/2] [IMP] endpoint_route_handler: detect route conflicts When the routing map is built, check each custom route (stored or generated) against the routes already loaded: standard controllers and other custom routes. A route w/ the same URL and overlapping methods (no methods = any) was silently shadowed (werkzeug matches the first rule only). Now it's skipped and an error is logged w/ both owners. The same URL w/ different methods is still fine. --- endpoint_route_handler/models/ir_http.py | 44 +++++-- endpoint_route_handler/readme/USAGE.md | 9 ++ .../tests/test_generator.py | 116 +++++++++++++++++- endpoint_route_handler/utils.py | 68 ++++++++++ 4 files changed, 227 insertions(+), 10 deletions(-) diff --git a/endpoint_route_handler/models/ir_http.py b/endpoint_route_handler/models/ir_http.py index edfa051c..e7d7963e 100644 --- a/endpoint_route_handler/models/ir_http.py +++ b/endpoint_route_handler/models/ir_http.py @@ -3,13 +3,13 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import logging -from itertools import chain import werkzeug from odoo import http, models, tools from ..registry import EndpointRegistry +from ..utils import RouteConflictTracker, controller_owner, rule_owner _logger = logging.getLogger(__name__) @@ -21,19 +21,44 @@ def _endpoint_route_registry(self, cr=None): return EndpointRegistry.registry_for(cr or self.env.cr) def _generate_routing_rules(self, modules, converters): - # Override to inject custom endpoint rules. - return chain( - super()._generate_routing_rules(modules, converters), - self._endpoint_routing_rules(), - ) + """Inject custom endpoint rules, skipping the conflicting ones. + + Standard controllers come first and are only tracked. + Each custom rule is checked against everything tracked so far + (see `RouteConflictTracker`): the 1st rule wins, as in werkzeug, + conflicting ones are logged and skipped. + + Cost: one dict lookup per rule (index by URL), negligible compared + to the build of the routing map (~3 ms for ~800 rules). + """ + tracker = RouteConflictTracker() + for url, endpoint in super()._generate_routing_rules(modules, converters): + # Standard controllers are not checked against each other + tracker.add(url, endpoint, controller_owner(endpoint)) + yield url, endpoint + for endpoint_rule, url, endpoint in self._endpoint_routing_rules(): + owner = rule_owner(endpoint_rule) + conflict = tracker.conflict(url, endpoint) + if conflict: + _logger.error( + "Route %s %s of %s conflicts w/ %s: skipped", + url, + tracker.methods_label(endpoint), + owner, + conflict, + ) + continue + tracker.add(url, endpoint, owner) + yield url, endpoint def _endpoint_routing_rules(self): - """Yield custom endpoint rules""" + """Yield `(endpoint rule, url, endpoint)` for all the custom endpoint rules.""" e_registry = self._endpoint_route_registry() for endpoint_rule in e_registry.get_rules(): _logger.debug("LOADING %s", endpoint_rule) if not endpoint_rule.is_generator: - yield from endpoint_rule.iter_routing_rules(self.env) + for url, endpoint in endpoint_rule.iter_routing_rules(self.env): + yield endpoint_rule, url, endpoint continue # A broken generator must not break the whole routing map. # Collect its rules first, so that nothing is yielded on failure. @@ -42,7 +67,8 @@ def _endpoint_routing_rules(self): except Exception: _logger.exception("Routes generation failed for %s", endpoint_rule) continue - yield from rules + for url, endpoint in rules: + yield endpoint_rule, url, endpoint @tools.ormcache("key", "cls._endpoint_route_last_version()", cache="routing") def routing_map(cls, key=None): diff --git a/endpoint_route_handler/readme/USAGE.md b/endpoint_route_handler/readme/USAGE.md index ef7e64ce..5698aea1 100644 --- a/endpoint_route_handler/readme/USAGE.md +++ b/endpoint_route_handler/readme/USAGE.md @@ -108,3 +108,12 @@ 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). + +## Route conflicts + +When the routing map is built, each custom route (stored or generated) +is checked against the routes already loaded (standard controllers +and other custom routes). A route w/ the same URL and overlapping HTTP methods +(no methods means any method) is skipped and an error is logged w/ both owners: +the first route wins, as werkzeug would do, but the conflict is not silent anymore. +The same URL w/ different methods (eg: GET and POST) is fine. diff --git a/endpoint_route_handler/tests/test_generator.py b/endpoint_route_handler/tests/test_generator.py index b91af1c2..a1390b8e 100644 --- a/endpoint_route_handler/tests/test_generator.py +++ b/endpoint_route_handler/tests/test_generator.py @@ -4,6 +4,7 @@ from unittest import mock +from odoo import http from odoo.tests.common import HttpCase, TransactionCase, tagged from odoo.tools import mute_logger @@ -148,7 +149,9 @@ def test_routing_rules_skip_broken_generator(self): ) 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()] + 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")]) @@ -192,3 +195,114 @@ def test_routing_map_wo_request(self): 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) + + +@tagged("-at_install", "post_install") +class TestRouteConflicts(TransactionCase, GeneratorMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.reg = EndpointRegistry.registry_for(cls.env.cr) + + def setUp(self): + super().setUp() + EndpointRegistry.wipe_registry_for(self.env.cr) + + def _installed_modules(self): + """Installed modules, to build the routing rules.""" + # Not `registry._init_modules`: it can be empty while running tests + return sorted( + self.env["ir.module.module"] + .search([("state", "=", "installed")]) + .mapped("name") + ) + + def _controller_func(self, url): + """Return the standard controller method serving `url`.""" + converters = self.env["ir.http"]._get_converters() + for rule_url, endpoint in http._generate_routing_rules( + self._installed_modules(), False, converters + ): + if rule_url == url: + return getattr(endpoint, "func", endpoint) + + def _generated(self, prefix): + """Build the routing rules: return `(url, methods)` of the fake generated ones. + + Only URLs starting w/ `prefix`, served by `GeneratorFake.handle`. + """ + ir_http = self.env["ir.http"] + res = [] + for url, endpoint in ir_http._generate_routing_rules( + self._installed_modules(), converters=ir_http._get_converters() + ): + if url.startswith(prefix) and endpoint.func.__name__ == "handle": + res.append((url, tuple(endpoint.routing["methods"] or ()))) + return sorted(res) + + def test_same_url_same_methods(self): + """Same URL and methods: the 1st rule wins, the 2nd is skipped and logged.""" + # Both generate GET `/conf/one` and `/conf/two` + names = ("one", "two") + self.reg.update_rules( + [ + self._make_generator_rule("gen1", "/conf", names=names), + self._make_generator_rule("gen2", "/conf", names=names), + ] + ) + logger = "odoo.addons.endpoint_route_handler.models.ir_http" + with self.assertLogs(logger, "ERROR") as logs: + res = self._generated("/conf") + # Only once each + self.assertEqual(res, [("/conf/one", ("GET",)), ("/conf/two", ("GET",))]) + # One error per skipped route, naming both rules + self.assertEqual( + [rec.getMessage() for rec in logs.records], + [ + "Route /conf/one GET of endpoint rule gen2 " + "conflicts w/ endpoint rule gen1: skipped", + "Route /conf/two GET of endpoint rule gen2 " + "conflicts w/ endpoint rule gen1: skipped", + ], + ) + + def test_same_url_other_methods(self): + """Same URL w/ different methods: both are loaded.""" + names = ("one", "two") + # Same URLs, but POST instead of GET + post = _generator_options(names=names, default_kwargs={"methods": ["POST"]}) + self.reg.update_rules( + [ + self._make_generator_rule("gen1", "/conf", names=names), + self._make_generator_rule("gen2", "/conf", options=post), + ] + ) + res = self._generated("/conf") + self.assertEqual( + res, + [ + ("/conf/one", ("GET",)), + ("/conf/one", ("POST",)), + ("/conf/two", ("GET",)), + ("/conf/two", ("POST",)), + ], + ) + + def test_conflict_w_controller(self): + """A route shadowing a standard controller (`/web/login`) is skipped.""" + # Generates GET `/web/login`, already served by a controller + # (`web` or an override, depending on the installed modules) + func = self._controller_func("/web/login") + rule = self._make_generator_rule("gen1", "/web", names=("login",)) + self.reg.update_rules([rule]) + logger = "odoo.addons.endpoint_route_handler.models.ir_http" + with self.assertLogs(logger, "ERROR") as logs: + res = self._generated("/web/login") + self.assertFalse(res) + self.assertEqual( + [rec.getMessage() for rec in logs.records], + [ + "Route /web/login GET of endpoint rule gen1 conflicts w/ " + f"controller {func.__module__}.{func.__qualname__}: skipped", + ], + ) diff --git a/endpoint_route_handler/utils.py b/endpoint_route_handler/utils.py index 589cca95..da883a91 100644 --- a/endpoint_route_handler/utils.py +++ b/endpoint_route_handler/utils.py @@ -58,3 +58,71 @@ def import_method(klass_dotted_path, method_name): return getattr(klass(), method_name) except AttributeError as exc: raise EndpointHandlerNotFound(f"Method name `{method_name}` not found") from exc + + +# Methods of a route declaring none: any method is served +ANY_METHODS = object() + + +def controller_owner(endpoint): + """Human readable owner of a standard controller route, for logs.""" + func = getattr(endpoint, "func", endpoint) + return f"controller {func.__module__}.{func.__qualname__}" + + +def rule_owner(endpoint_rule): + """Human readable owner of a custom endpoint rule route, for logs.""" + return f"endpoint rule {endpoint_rule.key}" + + +class RouteConflictTracker: + """Track the routes of the routing map to detect conflicting ones. + + Without this check, a route declared twice is silently shadowed: + werkzeug matches the first rule only. + + Matching rules: + + * URL: exact match of the URL as declared (routes are indexed by URL, + one dict lookup). URLs that differ only by their placeholders + (eg: `/foo/` vs `/foo/`) are not detected. + * methods: the sets of methods intersect, eg: `{GET, POST}` vs `{POST}`. + No methods means any method: it overlaps w/ everything. + Same URL w/ different methods is fine, eg: GET and POST on `/foo`. + + A plain hash of `(url, methods)` would only catch identical method sets. + """ + + def __init__(self): + # URL -> list of `(methods, owner)` + self._routes = {} + + @staticmethod + def methods(endpoint): + """Methods of an endpoint as a set, `ANY_METHODS` if none is declared.""" + methods = endpoint.routing.get("methods") + return set(methods) if methods else ANY_METHODS + + @classmethod + def methods_label(cls, endpoint): + """Methods of an endpoint for logs.""" + methods = cls.methods(endpoint) + if methods is ANY_METHODS: + return "(any method)" + return ",".join(sorted(methods)) + + def add(self, url, endpoint, owner): + """Remember the methods served on `url` and by whom.""" + self._routes.setdefault(url, []).append((self.methods(endpoint), owner)) + + def conflict(self, url, endpoint): + """Return the owner of a route already serving `url` w/ overlapping methods.""" + methods = self.methods(endpoint) + for other_methods, owner in self._routes.get(url, ()): + if ( + methods is ANY_METHODS + or other_methods is ANY_METHODS + or methods & other_methods + ): + return owner + return None From 648b2fcad5f2d306a0951ed6707058c2e48c69d0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2026 16:37:34 +0200 Subject: [PATCH 2/2] [FIX] endpoint_route_handler: stable endpoint hash `endpoint_hash` was computed w/ the builtin `hash()`, which is randomized per process for strings: the same route got a different hash after a restart, so `unique(endpoint_hash)` only caught duplicates computed in the same process. Use a md5 of the routing impacting values instead (`utils.stable_hash`). The migration (end of the update, once all modules are loaded) forcedly registers again the routes in sync of all the consumer models: rows are dropped and created w/ the new hash. Records not in sync w/ the registry get their new hash when they are synced. Consumer models now include indirect ones (eg: `endpoint.endpoint` via `endpoint.mixin`, `edi.endpoint`): they were missing, hence routes were not checked for uniqueness across them either. --- endpoint/tests/test_endpoint.py | 60 +++++++++++++++++++ endpoint_route_handler/__manifest__.py | 2 +- .../migrations/18.0.1.3.0/end-migrate.py | 32 ++++++++++ .../models/endpoint_route_handler.py | 33 ++++++---- endpoint_route_handler/tests/test_endpoint.py | 26 ++++++++ endpoint_route_handler/utils.py | 12 ++++ 6 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 endpoint_route_handler/migrations/18.0.1.3.0/end-migrate.py diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py index 9827b31a..352e0c6f 100644 --- a/endpoint/tests/test_endpoint.py +++ b/endpoint/tests/test_endpoint.py @@ -2,6 +2,7 @@ # @author: Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import importlib.util import json import textwrap from datetime import date @@ -12,6 +13,7 @@ from odoo import exceptions from odoo.http import Response +from odoo.tools import file_path from odoo.tools.misc import mute_logger from odoo.addons.endpoint.controllers.main import EndpointController @@ -26,8 +28,55 @@ def _setup_records(cls): cls.endpoint = cls.env.ref("endpoint.endpoint_demo_1") return res + def test_endpoint_route_consumer_models(self): + """Indirect inheritors are consumers (via `endpoint.mixin`), mixins are not.""" + models = self.env[ + "endpoint.route.handler" + ]._get_endpoint_route_consumer_models() + self.assertIn("endpoint.endpoint", models) + self.assertNotIn("endpoint.mixin", models) + + # Migration test: can be trashed when migrating to the next version + def test_migration_register_again(self): + """Routes in sync get the new hash, the others keep the old one.""" + in_sync = self.endpoint + out_of_sync = self.env["endpoint.endpoint"].create( + { + "name": "Out of sync", + "route": "/demo/out_of_sync", + "request_method": "GET", + "exec_mode": "code", + "code_snippet": 'result = {"response": Response("ok")}', + } + ) + (in_sync + out_of_sync)._register_controllers() + in_sync.registry_sync = True + keys = ( + in_sync._endpoint_registry_unique_key(), + out_of_sync._endpoint_registry_unique_key(), + ) + # Simulate hashes computed w/ the old randomized `hash()` + self.env.cr.execute( + "UPDATE endpoint_route SET endpoint_hash = 'old-' || id WHERE key IN %s", + (keys,), + ) + migration = self._load_migration("18.0.1.3.0", "end-migrate.py") + migration.migrate(self.env.cr, "18.0.1.2.0") + reg = in_sync._endpoint_registry + self.assertEqual(reg._get_rule(keys[0]).endpoint_hash, in_sync.endpoint_hash) + self.assertTrue(reg._get_rule(keys[1]).endpoint_hash.startswith("old-")) + + def _load_migration(self, version, filename): + """Import a migration script of `endpoint_route_handler` (not a package).""" + path = file_path(f"endpoint_route_handler/migrations/{version}/{filename}") + spec = importlib.util.spec_from_file_location("erh_migration", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + @mute_logger("odoo.sql_db") def test_endpoint_unique(self): + """Two endpoints can't share the same route.""" with self.assertRaises(psycopg2.IntegrityError): self.env["endpoint.endpoint"].create( { @@ -38,6 +87,7 @@ def test_endpoint_unique(self): ) def test_endpoint_validation(self): + """Invalid endpoints are refused: no code, or POST/PUT w/o content type.""" with self.assertRaisesRegex( exceptions.UserError, r"you must provide a piece of code" ): @@ -69,11 +119,13 @@ def test_endpoint_validation(self): self.endpoint.request_method = "POST" def test_endpoint_find(self): + """An endpoint is found by its route.""" self.assertEqual( self.env["endpoint.endpoint"]._find_endpoint("/demo/one"), self.endpoint ) def test_endpoint_code_eval_full_response(self): + """Code can return a full response: 200 OK w/ the body.""" with self._get_mocked_request() as req: result = self.endpoint._handle_request(req) resp = result["response"] @@ -81,6 +133,7 @@ def test_endpoint_code_eval_full_response(self): self.assertEqual(resp.data, b"ok") def test_endpoint_code_eval_free_vals(self): + """Code can set the payload and headers of the response.""" self.endpoint.write( { "code_snippet": textwrap.dedent( @@ -99,6 +152,7 @@ def test_endpoint_code_eval_free_vals(self): self.assertEqual(json.loads(payload), {"a": 1, "b": 2}) def test_endpoint_log(self): + """Logging from the code doesn't break the request.""" self.endpoint.write( { "code_snippet": textwrap.dedent( @@ -118,6 +172,7 @@ def test_endpoint_log(self): @mute_logger("endpoint.endpoint", "odoo.modules.registry") def test_endpoint_validate_request(self): + """Requests w/ the wrong content type or method are rejected (415, 405).""" endpoint = self.endpoint.copy( { "route": "/wrong", @@ -137,6 +192,7 @@ def test_endpoint_validate_request(self): @mute_logger("odoo.modules.registry") def test_routing(self): + """Routing info follows the fields of the endpoint, prefix included.""" route, info, __ = self.endpoint._get_routing_info() self.assertEqual(route, "/demo/one") self.assertEqual( @@ -189,6 +245,7 @@ def test_routing(self): @mute_logger("odoo.modules.registry") def test_unlink(self): + """Deleting an endpoint removes its routing rule.""" endpoint = self.endpoint.copy( { "route": "/delete/this", @@ -207,6 +264,7 @@ def test_unlink(self): @mute_logger("odoo.modules.registry") def test_archive(self): + """Archiving an endpoint removes its routing rule.""" endpoint = self.endpoint.copy( { "route": "/enable-disable/this", @@ -226,6 +284,7 @@ def test_archive(self): self.assertEqual(reg._get_rule(key), None) def test_registry_sync(self): + """A new endpoint is registered only once `registry_sync` is set.""" endpoint = self.env["endpoint.endpoint"].create( { "name": "New", @@ -249,6 +308,7 @@ def test_registry_sync(self): ) def test_duplicate(self): + """Copying an endpoint changes its route: no clash w/ the original.""" endpoint = self.endpoint.copy() self.assertTrue(endpoint.route.endswith("/COPY_FIXME")) diff --git a/endpoint_route_handler/__manifest__.py b/endpoint_route_handler/__manifest__.py index 4df21031..98766ea6 100644 --- a/endpoint_route_handler/__manifest__.py +++ b/endpoint_route_handler/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Endpoint route handler", "summary": """Provide mixin and tool to generate custom endpoints on the fly.""", - "version": "18.0.1.2.1", + "version": "18.0.1.3.0", "license": "LGPL-3", "development_status": "Beta", "author": "Camptocamp,Odoo Community Association (OCA)", diff --git a/endpoint_route_handler/migrations/18.0.1.3.0/end-migrate.py b/endpoint_route_handler/migrations/18.0.1.3.0/end-migrate.py new file mode 100644 index 00000000..3fc4508e --- /dev/null +++ b/endpoint_route_handler/migrations/18.0.1.3.0/end-migrate.py @@ -0,0 +1,32 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + """Register again all routes in sync: their hash is now stable across processes. + + Rows are dropped and created again to enforce the new hash. + Run at the end of the update: consumer models (eg: `endpoint.endpoint`) + belong to modules loaded after this one. + """ + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + # No cache: the registry might have been partially loaded when cached + model_names = env["endpoint.route.handler"]._find_endpoint_route_consumer_models() + for model_name in model_names: + records = ( + env[model_name] + .with_context(active_test=False) + .search([("registry_sync", "=", True)]) + ) + records._unregister_controllers(clear_cache=False) + records.filtered("active")._register_controllers(clear_cache=False) + _logger.info("%s: registered %s routes again", model_name, len(records)) + env.registry.clear_cache("routing") diff --git a/endpoint_route_handler/models/endpoint_route_handler.py b/endpoint_route_handler/models/endpoint_route_handler.py index 206e1837..c0c6bacf 100644 --- a/endpoint_route_handler/models/endpoint_route_handler.py +++ b/endpoint_route_handler/models/endpoint_route_handler.py @@ -6,6 +6,8 @@ from odoo import _, api, exceptions, fields, models +from ..utils import stable_hash + ENDPOINT_ROUTE_CONSUMER_MODELS = { # by db } @@ -89,21 +91,32 @@ def _check_route_unique_across_models(self): ) def _get_endpoint_route_consumer_models(self): + """Return the concrete models using `endpoint.route.handler`. + + Indirect inheritance included (eg: `endpoint.endpoint` via `endpoint.mixin`). + """ global ENDPOINT_ROUTE_CONSUMER_MODELS if ENDPOINT_ROUTE_CONSUMER_MODELS.get(self.env.cr.dbname): return ENDPOINT_ROUTE_CONSUMER_MODELS.get(self.env.cr.dbname) - models = [] - route_model = "endpoint.route.handler" - for model in self.env.values(): - if ( - model._name != route_model - and not model._abstract - and route_model in model._inherit - ): - models.append(model._name) + models = self._find_endpoint_route_consumer_models() ENDPOINT_ROUTE_CONSUMER_MODELS[self.env.cr.dbname] = models return models + def _find_endpoint_route_consumer_models(self): + """Look up the persistent models using `endpoint.route.handler` (no cache). + + Transient models are tools (eg: `endpoint.route.handler.tool`): their + records are vacuumed by Odoo, so their routes must not be synced. + """ + route_model = "endpoint.route.handler" + return sorted( + name + for name in self.env.registry.descendants([route_model], "_inherit") + if name != route_model + and not self.env[name]._abstract + and not self.env[name]._transient + ) + @property def _logger(self): return logging.getLogger(self._name) @@ -144,7 +157,7 @@ def _compute_endpoint_hash(self): ] for rec, vals in zip(self, values, strict=True): vals.pop("id", None) - rec.endpoint_hash = hash(tuple(vals.values())) + rec.endpoint_hash = stable_hash(vals) def _routing_impacting_fields(self): return ("route", "auth_type", "request_method") diff --git a/endpoint_route_handler/tests/test_endpoint.py b/endpoint_route_handler/tests/test_endpoint.py index c86ee7c4..172bca59 100644 --- a/endpoint_route_handler/tests/test_endpoint.py +++ b/endpoint_route_handler/tests/test_endpoint.py @@ -1,6 +1,8 @@ # Copyright 2021 Camptocamp SA # @author: Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import hashlib +import json from contextlib import contextmanager from unittest.mock import patch @@ -43,6 +45,7 @@ def tearDown(self): super().tearDown() def test_routing_map_no_request(self): + """The routing map builds w/o a bound request (crons, shell, tests).""" # Crons, `odoo shell` and core tests build the routing map with no # request bound. website's TestWebsiteTechnicalPage does, through # website.technical.page.get_static_routes(). No mocked request here @@ -50,7 +53,27 @@ def test_routing_map_no_request(self): self.env.registry.clear_cache("routing") self.assertTrue(self.env["ir.http"].routing_map()) + def test_endpoint_hash_stable(self): + """The hash is a md5 of the routing values: the same in every process.""" + new_route = make_new_route(self.env) + values = { + fname: new_route[fname] for fname in new_route._routing_impacting_fields() + } + # Same as computed in any other process: plain md5 of the values + data = json.dumps(values, sort_keys=True, default=str) + expected = hashlib.md5(data.encode(), usedforsecurity=False).hexdigest() + self.assertEqual(new_route.endpoint_hash, expected) + self.assertEqual(len(new_route.endpoint_hash), 32) + + def test_consumer_models_exclude_tool(self): + """Transient tools are not consumers: their routes are never synced.""" + models = self.env[ + "endpoint.route.handler" + ]._find_endpoint_route_consumer_models() + self.assertNotIn("endpoint.route.handler.tool", models) + def test_as_tool_base_data(self): + """Changing the route of a tool changes its hash.""" new_route = make_new_route(self.env) self.assertEqual(new_route.route, "/my/test/route") first_hash = new_route.endpoint_hash @@ -81,6 +104,7 @@ def _fake_compute_route(self): @mute_logger("odoo.addons.base.models.ir_http") def test_as_tool_register_single_controller(self): + """A registered tool is served at its route, and follows route changes.""" new_route = make_new_route(self.env) options = { "handler": { @@ -105,6 +129,7 @@ def test_as_tool_register_single_controller(self): @mute_logger("odoo.addons.base.models.ir_http") def test_as_tool_register_controllers(self): + """Registered tools are served at their route, and follow route changes.""" new_route = make_new_route(self.env) options = { "handler": { @@ -129,6 +154,7 @@ def test_as_tool_register_controllers(self): @mute_logger("odoo.addons.base.models.ir_http") def test_as_tool_register_controllers_dynamic_route(self): + """A route w/ a model converter is registered as given.""" route = "/my/app/" new_route = make_new_route(self.env, route=route) options = { diff --git a/endpoint_route_handler/utils.py b/endpoint_route_handler/utils.py index da883a91..aa68161f 100644 --- a/endpoint_route_handler/utils.py +++ b/endpoint_route_handler/utils.py @@ -4,7 +4,9 @@ """Helpers to build endpoints for the routing map.""" import functools +import hashlib import importlib +import json from odoo import http @@ -126,3 +128,13 @@ def conflict(self, url, endpoint): ): return owner return None + + +def stable_hash(values): + """Return a hash of `values` (a dict) that is the same in every process. + + The builtin `hash()` of strings is randomized per process: + it cannot be stored and compared later. + """ + data = json.dumps(values, sort_keys=True, default=str) + return hashlib.md5(data.encode(), usedforsecurity=False).hexdigest()