diff --git a/endpoint_spec/README.rst b/endpoint_spec/README.rst new file mode 100644 index 00000000..b6790cf4 --- /dev/null +++ b/endpoint_spec/README.rst @@ -0,0 +1 @@ +waiting the bot :) diff --git a/endpoint_spec/__init__.py b/endpoint_spec/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/endpoint_spec/__manifest__.py b/endpoint_spec/__manifest__.py new file mode 100644 index 00000000..282a8a9a --- /dev/null +++ b/endpoint_spec/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Endpoint spec", + "summary": "Declare endpoints on any class: metadata for routes generators.", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "development_status": "Alpha", + "author": "Camptocamp,Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "website": "https://github.com/OCA/web-api", + "depends": ["base"], +} diff --git a/endpoint_spec/endpoint.py b/endpoint_spec/endpoint.py new file mode 100644 index 00000000..76439beb --- /dev/null +++ b/endpoint_spec/endpoint.py @@ -0,0 +1,207 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""Declare endpoints on classes that are not controllers. + +Controllers declare their routes w/ `odoo.http.route`. +This module is for service-like classes whose methods are turned into +routes by a generator (see `endpoint_route_handler` generators): + + class MyService: + + @endpoint("GET", path="/") + def get(self, id): + ... + + @endpoint(input=MyParam(), output=MyParam()) + def scan(self, barcode): + ... + + specs = collect_endpoints(MyService) + +The decorator only declares metadata: it never wraps the method. +Validation happens in the dispatcher, at the HTTP boundary. +Hence, methods can call each other and tests can call them directly. +""" + +import dataclasses +import inspect +from collections.abc import Mapping +from typing import Any + +ENDPOINT_ATTR = "_endpoint_spec" +ENDPOINT_EXTEND_ATTR = "_endpoint_spec_extend" + + +@dataclasses.dataclass(frozen=True) +class EndpointRoute: + """Paths served with the same HTTP methods.""" + + paths: tuple + methods: tuple + + +@dataclasses.dataclass(frozen=True) +class EndpointSpec: + """Metadata of an endpoint. + + :param routes: tuple of `EndpointRoute`. + Paths are relative to the root of the generator. + When no route is given, conventions provide the default one. + :param methods: HTTP methods to use for the default route + :param input: param object for request params (eg: an `http_api_dispatcher` param) + :param output: param object for the result + :param routing: any other routing key (auth, cors, csrf, api_doc, ...) + """ + + routes: tuple = () + methods: tuple = () + input: Any = None + output: Any = None + routing: Mapping = dataclasses.field(default_factory=dict) + + def merge(self, **values): + """Return a new spec updated with given values. + + `routing` values are merged, other values are replaced. + """ + if "routing" in values: + values["routing"] = dict(self.routing, **values["routing"]) + return dataclasses.replace(self, **values) + + +def _as_tuple(value): + """Normalize a single value or an iterable to a tuple.""" + if value is None: + return () + if isinstance(value, str): + return (value,) + return tuple(value) + + +def endpoint(methods="POST", path=None, input=None, output=None, **routing): + """Declare a method as endpoint. + + :param methods: HTTP method(s) + :param path: path(s) relative to the root of the generator. + If not given, conventions provide the default path. + :param input: param object for request params + :param output: param object for the result + :param routing: any other routing key (auth, cors, csrf, api_doc, ...) + """ + methods = _as_tuple(methods) + routes = () + if path: + routes = (EndpointRoute(paths=_as_tuple(path), methods=methods),) + spec = EndpointSpec( + routes=routes, methods=methods, input=input, output=output, routing=routing + ) + + def deco(func): + """Store the spec on the function, untouched.""" + setattr(func, ENDPOINT_ATTR, spec) + return func + + return deco + + +def extend(**values): + """Update the spec inherited from parent classes. + + Use it on overrides to change only some values: + + @endpoint.extend(routing={"api_doc": {"tags": ["new"]}}) + def scan(self, barcode): + return super().scan(barcode) + """ + + def deco(func): + """Store the values to merge into the inherited spec.""" + setattr(func, ENDPOINT_EXTEND_ATTR, values) + return func + + return deco + + +endpoint.extend = extend + + +def find_spec(cls, name): + """Return the spec of method `name` looking up the class hierarchy. + + An override without decorator keeps the spec of its parent. + `endpoint.extend` updates on overrides are applied + from the parent to the child. + """ + extensions = [] + for klass in cls.__mro__: + func = klass.__dict__.get(name) + if func is None: + continue + func = getattr(func, "__func__", func) + ext = getattr(func, ENDPOINT_EXTEND_ATTR, None) + if ext: + extensions.append(ext) + spec = getattr(func, ENDPOINT_ATTR, None) + if spec is not None: + for ext in reversed(extensions): + spec = spec.merge(**ext) + return spec + return None + + +def public_methods(cls): + """Return `(name, func)` of public methods without triggering properties.""" + res = [] + for attr in inspect.classify_class_attrs(cls): + if attr.kind != "method" or attr.name.startswith("_"): + continue + res.append((attr.name, getattr(cls, attr.name))) + return sorted(res) + + +class Conventions: + """Provide defaults for endpoint specs. + + The base class exposes only decorated methods + and uses `/` as default path. + This is the recommended behavior: endpoints are declared explicitly. + + Subclasses can expose non decorated methods via `guess`. + This is meant only as a transition, eg: to migrate services from `base_rest` + (see `ShopfloorConventions` in `shopfloor_base`). + """ + + def guess(self, cls, name, func): + """Return a spec for a non decorated method or None to skip it.""" + return None + + def default_routes(self, cls, name, func, methods=None): + """Default route: POST (or given methods) on `/`.""" + methods = _as_tuple(methods) or ("POST",) + return (EndpointRoute(paths=(f"/{name}",), methods=methods),) + + def complete(self, cls, name, func, spec): + """Fill the missing values of a spec.""" + if not spec.routes: + routes = self.default_routes(cls, name, func, spec.methods or None) + spec = spec.merge(routes=routes) + return spec + + +def collect_endpoints(cls, conventions=None): + """Return `{method name: EndpointSpec}` for the endpoints of `cls`. + + Decorated methods (on the class or its parents) are always collected. + Non decorated public methods are passed to `conventions.guess`. + """ + conventions = conventions or Conventions() + res = {} + for name, func in public_methods(cls): + spec = find_spec(cls, name) + if spec is None: + spec = conventions.guess(cls, name, func) + if spec is None: + continue + res[name] = conventions.complete(cls, name, func, spec) + return res diff --git a/endpoint_spec/pyproject.toml b/endpoint_spec/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/endpoint_spec/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/endpoint_spec/readme/CONTRIBUTORS.md b/endpoint_spec/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2b66303a --- /dev/null +++ b/endpoint_spec/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Simone Orsi \<\> diff --git a/endpoint_spec/readme/DESCRIPTION.md b/endpoint_spec/readme/DESCRIPTION.md new file mode 100644 index 00000000..1e59ce3e --- /dev/null +++ b/endpoint_spec/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +Declare endpoints on any class (eg: services), not only on controllers. + +Provides the metadata that routes generators (see `endpoint_route_handler`) +read to build routes: HTTP methods, paths, input/output params and any routing key. + +- `endpoint` decorator: declares metadata only, it never wraps the method +- `EndpointSpec`: the metadata of an endpoint +- `collect_endpoints`: collect the specs of a class, following inheritance +- conventions: defaults for non decorated methods + +It does not depend on any dispatcher: generated routes can use +any route type (eg: `http_api`, `restapi` from `base_rest`). diff --git a/endpoint_spec/readme/USAGE.md b/endpoint_spec/readme/USAGE.md new file mode 100644 index 00000000..3bb4230a --- /dev/null +++ b/endpoint_spec/readme/USAGE.md @@ -0,0 +1,38 @@ +## Declare endpoints + + from odoo.addons.endpoint_spec.endpoint import collect_endpoints, endpoint + + class MyService: + + @endpoint("GET", path="/") + def get(self, id): + ... + + @endpoint(input=MyParam(), output=MyParam()) + def scan(self, barcode): + ... + + specs = collect_endpoints(MyService) + # {"get": EndpointSpec(...), "scan": EndpointSpec(...)} + +The decorator only declares metadata: it does not wrap the method. +Methods can call each other and tests can call them directly. + +Specs are inherited: an override w/o decorator keeps the spec of its parent. +Use `@endpoint.extend(...)` on an override to change only some values. + +Decorated methods w/o path get `/` as default path. +Non decorated methods are not exposed. + +Use the specs in a routes generator: one route per path of each `EndpointRoute`, +w/ the HTTP methods, the `input` / `output` params and the `routing` keys of the spec. + +## Conventions + +By default only decorated methods are exposed: this is the recommended behavior. + +To expose non decorated methods, pass your own `Conventions` subclass +to `collect_endpoints` and implement `guess`. Use it only as a transition, +eg: to migrate services from `base_rest` w/o declaring every endpoint at once +(see `ShopfloorConventions` in `shopfloor_base`). With implicit exposure, +any new public method becomes an endpoint by mistake. diff --git a/endpoint_spec/static/description/icon.png b/endpoint_spec/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/endpoint_spec/static/description/icon.png differ diff --git a/endpoint_spec/tests/__init__.py b/endpoint_spec/tests/__init__.py new file mode 100644 index 00000000..c3f07a7d --- /dev/null +++ b/endpoint_spec/tests/__init__.py @@ -0,0 +1 @@ +from . import test_endpoint diff --git a/endpoint_spec/tests/test_endpoint.py b/endpoint_spec/tests/test_endpoint.py new file mode 100644 index 00000000..b988aa1d --- /dev/null +++ b/endpoint_spec/tests/test_endpoint.py @@ -0,0 +1,104 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests.common import BaseCase + +from ..endpoint import ( + Conventions, + EndpointRoute, + EndpointSpec, + collect_endpoints, + endpoint, + find_spec, +) + + +class Base: + def helper(self): + """Public method of the base class: never exposed by conventions.""" + + @property + def boom(self): + raise AssertionError("Properties must not be evaluated") + + +class Service(Base): + @endpoint("GET", path=["/", "//get"], auth="public") + def get(self, id): + return id + + @endpoint(input="in", output="out") + def scan(self, barcode): + return barcode + + @endpoint(["GET", "POST"]) + def multi(self): + return True + + def not_decorated(self): + return True + + def _private(self): + return True + + +class ServiceChild(Service): + def scan(self, barcode): + return super().scan(barcode) + + @endpoint.extend(routing={"api_doc": {"tags": ["child"]}}, output="out2") + def multi(self): + return super().multi() + + +def _routes(*pairs): + return tuple(EndpointRoute(paths=tuple(p), methods=tuple(m)) for p, m in pairs) + + +class TestEndpoint(BaseCase): + def test_decorator_does_not_wrap(self): + """The decorator only attaches metadata: the method is left as is.""" + self.assertEqual(Service().scan("abc"), "abc") + self.assertEqual(Service.scan.__name__, "scan") + + def test_collect_explicit_only(self): + """Only decorated methods are collected, w/ their routes and routing.""" + specs = collect_endpoints(Service) + self.assertEqual(sorted(specs), ["get", "multi", "scan"]) + self.assertEqual( + specs["get"].routes, _routes((("/", "//get"), ("GET",))) + ) + self.assertEqual(specs["get"].routing, {"auth": "public"}) + self.assertEqual(specs["scan"].routes, _routes((("/scan",), ("POST",)))) + self.assertEqual(specs["scan"].input, "in") + self.assertEqual(specs["multi"].routes, _routes((("/multi",), ("GET", "POST")))) + + def test_inherited_spec(self): + """Child classes inherit the specs of their parents and extend them.""" + specs = collect_endpoints(ServiceChild) + self.assertEqual(specs["scan"].input, "in") + self.assertEqual(specs["multi"].output, "out2") + self.assertEqual(specs["multi"].routing, {"api_doc": {"tags": ["child"]}}) + self.assertEqual(specs["multi"].routes, _routes((("/multi",), ("GET", "POST")))) + self.assertIsNone(find_spec(ServiceChild, "not_decorated")) + + def test_custom_conventions(self): + """Conventions can expose undecorated methods; explicit specs win.""" + + class ExposeAll(Conventions): + """Expose every public method not inherited from `Base`.""" + + def guess(self, cls, name, func): + if hasattr(Base, name): + return None + return EndpointSpec(methods=("PUT",)) + + specs = collect_endpoints(Service, conventions=ExposeAll()) + self.assertEqual(sorted(specs), ["get", "multi", "not_decorated", "scan"]) + self.assertNotIn("helper", specs) + self.assertEqual( + specs["not_decorated"].routes, _routes((("/not_decorated",), ("PUT",))) + ) + # Explicit specs are not affected by conventions + self.assertEqual(specs["scan"].routes, _routes((("/scan",), ("POST",))))