Skip to content
Draft
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
1 change: 1 addition & 0 deletions endpoint_spec/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
waiting the bot :)
Empty file added endpoint_spec/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions endpoint_spec/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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"],
}
207 changes: 207 additions & 0 deletions endpoint_spec/endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# 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="/<int:id>")
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 `/<method name>` 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 `/<method name>`."""
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
3 changes: 3 additions & 0 deletions endpoint_spec/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
1 change: 1 addition & 0 deletions endpoint_spec/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Simone Orsi \<<simone.orsi@camptocamp.com>\>
12 changes: 12 additions & 0 deletions endpoint_spec/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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`).
38 changes: 38 additions & 0 deletions endpoint_spec/readme/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## Declare endpoints

from odoo.addons.endpoint_spec.endpoint import collect_endpoints, endpoint

class MyService:

@endpoint("GET", path="/<int:id>")
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 `/<method name>` 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.
Binary file added endpoint_spec/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions endpoint_spec/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_endpoint
104 changes: 104 additions & 0 deletions endpoint_spec/tests/test_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# 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=["/<int:id>", "/<int:id>/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((("/<int:id>", "/<int:id>/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",))))
Loading