Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions endpoint/tests/test_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -187,7 +187,6 @@ def test_routing(self):
"readonly": False,
},
)
type(endpoint)._endpoint_route_prefix = ""

@mute_logger("odoo.modules.registry")
def test_unlink(self):
Expand Down
53 changes: 53 additions & 0 deletions endpoint_route_handler/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down Expand Up @@ -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
======================

Expand Down
14 changes: 11 additions & 3 deletions endpoint_route_handler/models/ir_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions endpoint_route_handler/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
49 changes: 49 additions & 0 deletions endpoint_route_handler/readme/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
99 changes: 82 additions & 17 deletions endpoint_route_handler/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import functools
import importlib
import json
import logging

Expand All @@ -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__)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", {})
Expand All @@ -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.

Expand All @@ -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
Loading
Loading