diff --git a/http_api_dispatcher/README.rst b/http_api_dispatcher/README.rst new file mode 100644 index 00000000..b6790cf4 --- /dev/null +++ b/http_api_dispatcher/README.rst @@ -0,0 +1 @@ +waiting the bot :) diff --git a/http_api_dispatcher/__init__.py b/http_api_dispatcher/__init__.py new file mode 100644 index 00000000..0207a169 --- /dev/null +++ b/http_api_dispatcher/__init__.py @@ -0,0 +1,2 @@ +from . import dispatcher +from . import models diff --git a/http_api_dispatcher/__manifest__.py b/http_api_dispatcher/__manifest__.py new file mode 100644 index 00000000..e9b661cb --- /dev/null +++ b/http_api_dispatcher/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "HTTP API dispatcher", + "summary": "Generic dispatcher for JSON APIs: parsing, validation, errors.", + "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/http_api_dispatcher/call.py b/http_api_dispatcher/call.py new file mode 100644 index 00000000..01351caa --- /dev/null +++ b/http_api_dispatcher/call.py @@ -0,0 +1,82 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import dataclasses +from functools import cached_property +from typing import Any + +from .parsers import read_body, read_query + +# Transport data: the CSRF token is never a param of the endpoint +CSRF_TOKEN_PARAM = "csrf_token" + + +@dataclasses.dataclass +class ApiCall: + """State of one request going through the `http_api` pipeline. + + Hooks and params receive the call instead of loose arguments, + so that new attributes can be added without breaking signatures. + + :param request: the current `odoo.http.Request` + :param endpoint: the matched endpoint + :param args: arguments extracted from the URL (eg: ``) + The query string and the body are parsed on first access. + The pipeline reads `params` to call the endpoint, so routes parse both today. + + :param result: result of the endpoint (validated after output validation) + :param extra: free storage for hooks and plugins + """ + + request: Any + endpoint: Any + args: dict + result: Any = None + extra: dict = dataclasses.field(default_factory=dict) + _params: Any = dataclasses.field(default=None, init=False, repr=False) + + @cached_property + def query(self): + """Query string params, parsed on first access.""" + return read_query(self.httprequest, self.routing) + + @cached_property + def body(self): + """Body params, parsed on first access.""" + return read_body(self.httprequest, self.routing) + + @property + def params(self): + """Params passed to the endpoint: query, then body, then URL args. + + Built on first access (raw params before input validation, + secure params after). The same dict is returned on each access. + """ + if self._params is None: + params = {**self.query, **self.body, **self.args} + params.pop(CSRF_TOKEN_PARAM, None) + self.params = params + return self._params + + @params.setter + def params(self, value): + self._params = value + # Keep the request in sync: error logs read it + self.request.params = value + + @property + def env(self): + # Always the current env: hooks might update it (eg: context) + """Current env: hooks might have changed it (eg: context).""" + return self.request.env + + @property + def routing(self): + """Routing of the endpoint: drives the behavior of the pipeline.""" + return self.endpoint.routing + + @property + def httprequest(self): + """Shortcut to the werkzeug request.""" + return self.request.httprequest diff --git a/http_api_dispatcher/dispatcher.py b/http_api_dispatcher/dispatcher.py new file mode 100644 index 00000000..a690b88f --- /dev/null +++ b/http_api_dispatcher/dispatcher.py @@ -0,0 +1,117 @@ +# Copyright 2018 ACSONE SA/NV +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +from collections import defaultdict + +from werkzeug.exceptions import BadRequest + +from odoo.http import ( + CSRF_FREE_METHODS, + MISSING_CSRF_WARNING, + Dispatcher, +) + +from .call import CSRF_TOKEN_PARAM, ApiCall +from .errors import ErrorMapper + +_logger = logging.getLogger(__name__) + +CSRF_TOKEN_HEADER = "X-CSRF-Token" + + +class HttpApiDispatcher(Dispatcher): + """Dispatcher for routes of type `http_api`. + + Heavily inspired by `RestApiDispatcher` in `base_rest.http` + (OCA/rest-framework): body parsing, CSRF check + and language detection follow the same rules. + + This class is process-global (like any Odoo dispatcher): + keep it thin and stateless. + Behaviors depending on installed modules + are delegated to `ir.http._http_api_*` hooks, + behaviors specific to a route are driven by its routing keys. + """ + + routing_type = "http_api" + + @classmethod + def is_compatible_with(cls, request): + """Accept any content type: parsers decide how to read the body.""" + return True + + def pre_dispatch(self, rule, args): + """Set the language before the endpoint is called. + + Params are not parsed here: see `ApiCall`. + Keep the routing: `handle_error` needs it to apply the route error map. + """ + res = super().pre_dispatch(rule, args) + self.routing = rule.endpoint.routing + self._set_lang_from_headers() + return res + + def dispatch(self, endpoint, args): + """Check CSRF and run the `ir.http` pipeline. + + The pipeline lives in `ir.http` so that it can be extended per database. + """ + call = ApiCall(request=self.request, endpoint=endpoint, args=args) + self._check_csrf(endpoint, call) + return self.request.registry["ir.http"]._http_api_run(call) + + def handle_error(self, exc): + """Return a JSON error response, honoring the error map of the route.""" + routing = getattr(self, "routing", None) + return ErrorMapper.for_routing(routing, request=self.request).to_response(exc) + + def _check_csrf(self, endpoint, call): + """Validate the CSRF token as Odoo does for `http` routes. + + The token is read from the `X-CSRF-Token` header first: + the query and the body are parsed only when the header is missing. + Token based APIs should disable the check via `csrf=False`. + """ + if self.request.httprequest.method in CSRF_FREE_METHODS: + return + if not endpoint.routing.get("csrf", True): + return + token = self.request.httprequest.headers.get(CSRF_TOKEN_HEADER) + if token is None: + token = call.body.get(CSRF_TOKEN_PARAM, call.query.get(CSRF_TOKEN_PARAM)) + if not self.request.validate_csrf(token): + if token is not None: + _logger.warning( + "CSRF validation failed on path '%s'", + self.request.httprequest.path, + ) + else: + _logger.warning(MISSING_CSRF_WARNING, self.request.httprequest.path) + raise BadRequest("Session expired (invalid CSRF token)") + + def _set_lang_from_headers(self): + """Set the lang in context from `Accept-Language` header. + + The first installed language matching the header, + by priority, is used: exact locale match first (eg: `fr_BE`) + then language match (eg: `fr` -> first installed `fr_*`). + """ + accepted = self.request.httprequest.accept_languages + if not accepted: + return + installed = set() + by_lang = defaultdict(list) + for code, __ in self.request.env["res.lang"].get_installed(): + installed.add(code) + by_lang[code.split("_")[0]].append(code) + for value in accepted.values(): + locale = value.replace("-", "_") + lang = locale if locale in installed else None + if not lang and by_lang.get(locale.split("_")[0]): + lang = by_lang[locale.split("_")[0]][0] + if lang: + self.request.update_context(lang=lang) + return diff --git a/http_api_dispatcher/encoder.py b/http_api_dispatcher/encoder.py new file mode 100644 index 00000000..18086414 --- /dev/null +++ b/http_api_dispatcher/encoder.py @@ -0,0 +1,29 @@ +# Copyright 2018 ACSONE SA/NV +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""JSON encoding of results. + +`JSONEncoder` is taken from `base_rest.http` (OCA/rest-framework). +""" + +import datetime +import decimal +import json + + +class JSONEncoder(json.JSONEncoder): + """Encode dates as ISO strings and decimals as floats.""" + + def default(self, obj): # pylint: disable=method-hidden + """Encode values not supported by the std encoder.""" + if isinstance(obj, datetime.datetime | datetime.date): + return obj.isoformat() + if isinstance(obj, decimal.Decimal): + return float(obj) + return super().default(obj) + + +def json_dumps(data, encoder=None): + """Serialize `data` with given encoder (default: `JSONEncoder`).""" + return json.dumps(data, cls=encoder or JSONEncoder) diff --git a/http_api_dispatcher/errors.py b/http_api_dispatcher/errors.py new file mode 100644 index 00000000..e6d29ef1 --- /dev/null +++ b/http_api_dispatcher/errors.py @@ -0,0 +1,195 @@ +# Copyright 2018 ACSONE SA/NV +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""Map exceptions to JSON responses. + +Heavily inspired by `wrapJsonException` and `RestApiDispatcher.handle_error` +in `base_rest.http` (OCA/rest-framework): same mapping of exceptions, +same body format and same log message. + +The format of the body is compatible with `base_rest`: + + {"code": 400, "name": "Bad Request", "description": "..."} + +plus any key provided by the `rest_json_info` attribute of the exception. +""" + +import logging +import traceback + +from markupsafe import escape +from werkzeug.exceptions import ( + BadRequest, + Forbidden, + HTTPException, + InternalServerError, + NotFound, + Unauthorized, +) + +from odoo.exceptions import ( + AccessDenied, + AccessError, + MissingError, + UserError, + ValidationError, +) +from odoo.http import Response, SessionExpiredException +from odoo.tools import str2bool +from odoo.tools.config import config + +from .encoder import json_dumps + +_logger = logging.getLogger(__name__) + +BLACKLISTED_LOG_PARAMS = ("password",) +BLACKLISTED_LOG_HEADERS = ("Api-Key", "Authorization", "Cookie") + + +class ApiValidationError(UserError): + """Raised when request params are not valid. + + The detail of the errors is returned in the `errors` key of the body. + """ + + def __init__(self, message, errors=None): + """Keep the details of the errors to return them to the client.""" + super().__init__(message) + self.rest_json_info = {"errors": errors} if errors else {} + + +# (exception classes, HTTP exception class, include description) +# Order matters: MissingError and AccessError are UserError subclasses. +DEFAULT_ERROR_MAP = ( + (SessionExpiredException, Unauthorized, False), + (MissingError, NotFound, False), + ((AccessError, AccessDenied), Forbidden, False), + ((UserError, ValidationError), BadRequest, True), +) + + +def _config_flag(section, key): + """Read a boolean from the configuration file. + + Values coming from the file are strings: "False" must be falsy. + """ + value = config.get_misc(section, key) + if isinstance(value, str): + return str2bool(value, default=False) + return bool(value) + + +def is_dev_mode(): + """Return True if errors must include their traceback.""" + return ( + _config_flag("http_api", "dev_mode") + # Backward compatibility w/ base_rest configuration + or _config_flag("base_rest", "dev_mode") + ) + + +class ErrorMapper: + """Turn any exception into a JSON response. + + The map can be extended per route via the `error_map` routing key: + its entries have the same format of `DEFAULT_ERROR_MAP` + and are evaluated first. + """ + + def __init__(self, error_map=None, request=None, encoder=None): + """Prepend route specific mappings to the default ones.""" + self.error_map = tuple(error_map or ()) + DEFAULT_ERROR_MAP + self.request = request + self.encoder = encoder + + @classmethod + def for_routing(cls, routing, request=None): + """Build a mapper from the routing keys of a route.""" + routing = routing or {} + return cls( + error_map=routing.get("error_map"), + request=request, + encoder=routing.get("json_encoder"), + ) + + def to_response(self, exc): + """Return the response for given exception, logging it.""" + if isinstance(exc, HTTPException) and ( + exc.response is not None or (exc.code or 0) < 400 + ): + # Explicit response (eg: `werkzeug.exceptions.abort(response)`) + # or not an error (eg: redirections). + return exc + http_exc, include_description = self._map(exc) + body = self._get_body(exc, http_exc, include_description) + self._log(exc, http_exc, body) + response = Response( + json_dumps(body, encoder=self.encoder), + status=http_exc.code, + content_type="application/json", + ) + for key, value in http_exc.get_headers(): + if key.lower() != "content-type": + response.headers[key] = value + return response + + def _map(self, exc): + """Return the HTTP exception to use and whether to show the description.""" + for exc_classes, http_exc_class, include_description in self.error_map: + if isinstance(exc, exc_classes): + return http_exc_class(self._message(exc)), include_description + if isinstance(exc, HTTPException): + # Client errors raised explicitly carry a meaningful description + return exc, exc.code < 500 + return InternalServerError(), False + + def _message(self, exc): + """Message of the exception, used as description.""" + return exc.args[0] if exc.args else str(exc) + + def _get_body(self, exc, http_exc, include_description): + """Build the JSON body in the format of `base_rest`.""" + body = {"code": http_exc.code, "name": str(escape(http_exc.name))} + if include_description or is_dev_mode(): + body["description"] = http_exc.description + if is_dev_mode(): + body["traceback"] = "".join(traceback.format_exception(exc)) + body.update(getattr(exc, "rest_json_info", None) or {}) + return body + + def _log(self, exc, http_exc, body): + """Log the error with the request details, w/o secrets.""" + request = self.request + if not request: + return + httprequest = request.httprequest + headers = { + k: v + for k, v in httprequest.headers.items() + if k not in BLACKLISTED_LOG_HEADERS + } + params = dict(getattr(request, "params", None) or {}) + for k in params: + if k in BLACKLISTED_LOG_PARAMS: + params[k] = "" + extra = { + "application": "REST Services", + "url": httprequest.url, + "method": httprequest.method, + "params": params, + "headers": headers, + "status": http_exc.code, + "exception_body": body, + } + # Same message as `base_rest` to not break existing log based monitoring + _logger.error( + "RESTFULL call to url %s with method %s and params %s " + "raise the following error %s", + httprequest.url, + httprequest.method, + params, + exc, + extra=extra, + exc_info=http_exc.code >= 500, + ) diff --git a/http_api_dispatcher/models/__init__.py b/http_api_dispatcher/models/__init__.py new file mode 100644 index 00000000..9a5eb718 --- /dev/null +++ b/http_api_dispatcher/models/__init__.py @@ -0,0 +1 @@ +from . import ir_http diff --git a/http_api_dispatcher/models/ir_http.py b/http_api_dispatcher/models/ir_http.py new file mode 100644 index 00000000..51c385f0 --- /dev/null +++ b/http_api_dispatcher/models/ir_http.py @@ -0,0 +1,86 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from werkzeug.wrappers import Response as WerkzeugResponse + +from odoo import models +from odoo.http import Response + +from ..encoder import json_dumps + + +def _as_list(value): + """Allow routing keys to hold a single value or a list.""" + if not value: + return [] + if isinstance(value, list | tuple): + return list(value) + return [value] + + +class IrHttp(models.AbstractModel): + """Hooks of the `http_api` pipeline. + + Each step can be extended by other modules via standard inheritance. + """ + + _inherit = "ir.http" + + @classmethod + def _http_api_run(cls, call): + """Run the full pipeline for the given `ApiCall`.""" + cls._http_api_prepare(call) + cls._http_api_validate_input(call) + call.result = cls._http_api_call(call) + cls._http_api_validate_output(call) + return cls._http_api_make_response(call) + + @classmethod + def _http_api_prepare(cls, call): + """Run the `prepare` callables of the route, if any. + + Use them to validate headers, update the context, etc. + They run before input validation. + """ + for prepare in _as_list(call.routing.get("prepare")): + prepare(call) + + @classmethod + def _http_api_validate_input(cls, call): + """Replace raw params with the ones validated by the `input` param. + + Non dict values are passed as a single keyword (`input_kwarg`). + """ + param = call.routing.get("input") + if not param: + return + params = param.from_params(call) + if not isinstance(params, dict): + params = {call.routing.get("input_kwarg", "payload"): params} + # Values from the URL always win + call.params = dict(params, **call.args) + + @classmethod + def _http_api_call(cls, call): + """Call the endpoint. Wrap this to log, profile, etc.""" + call.request.params = call.params + return cls._dispatch(call.endpoint) + + @classmethod + def _http_api_validate_output(cls, call): + """Validate the result via the `output` param, except for responses.""" + param = call.routing.get("output") + if not param or isinstance(call.result, WerkzeugResponse): + return + call.result = param.to_response(call, call.result) + + @classmethod + def _http_api_make_response(cls, call): + """Return responses as they are, serialize anything else as JSON.""" + if isinstance(call.result, WerkzeugResponse): + return call.result + return Response( + json_dumps(call.result, encoder=call.routing.get("json_encoder")), + content_type="application/json", + ) diff --git a/http_api_dispatcher/params.py b/http_api_dispatcher/params.py new file mode 100644 index 00000000..a36b617d --- /dev/null +++ b/http_api_dispatcher/params.py @@ -0,0 +1,48 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import abc + + +class ApiParam(abc.ABC): + """Convert and validate one side of an endpoint, and describe it. + + Instances are set on the routing of an endpoint + as `input` (request params) or `output` (endpoint result). + + `call` is duck-typed: it must expose `env`. + The dispatcher passes the current `ApiCall`, + API docs builders pass their own object + and other frameworks can pass their own objects (eg: a service). + """ + + @abc.abstractmethod + def from_params(self, call): + """Return the params to pass to the endpoint. + + `call` is the `ApiCall`: read `call.params` for the merged params, + or `call.query` / `call.body` when only one source is needed, + so that the other one is never parsed. + + Return a `dict` to pass them as keyword arguments, + any other value is passed as a single keyword argument + (see `input_kwarg` routing key). + + Raise `ApiValidationError` if params are not valid. + """ + + @abc.abstractmethod + def to_response(self, call, result): + """Return the result to serialize. + + Raise an exception if the result is not valid. + """ + + def to_json_schema(self, call, direction): + """Return the JSON schema of the param. + + :param direction: "input" or "output" + :return: a dict or None if the schema is not known + """ + return None diff --git a/http_api_dispatcher/parsers.py b/http_api_dispatcher/parsers.py new file mode 100644 index 00000000..9fc708d3 --- /dev/null +++ b/http_api_dispatcher/parsers.py @@ -0,0 +1,70 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""Default parsers for request bodies and query strings. + +Body parsers are selected by mimetype. +Each parser receives the werkzeug request and returns a dict. +Routes can override them via `body_parsers` and `query_parser` routing keys. +""" + +import json +import logging + +from werkzeug.exceptions import BadRequest + +_logger = logging.getLogger(__name__) + + +def parse_query(httprequest): + """Flat dict of query string params (last value wins).""" + return httprequest.args.to_dict(flat=True) + + +def parse_json(httprequest): + """Parse a JSON object from the body. + + Only objects are accepted as their keys become the params of the endpoint. + """ + charset = httprequest.mimetype_params.get("charset", "utf-8") + data = httprequest.get_data().decode(charset) + if not data: + return {} + try: + value = json.loads(data) + except ValueError as err: + msg = f"Invalid JSON data: {err}" + _logger.info("%s: %s", httprequest.path, msg) + raise BadRequest(msg) from err + if not isinstance(value, dict): + raise BadRequest("Invalid JSON data: an object is expected") + return value + + +def parse_form(httprequest): + """Form fields and files (flat).""" + params = httprequest.form.to_dict(flat=True) + params.update(httprequest.files.to_dict(flat=True)) + return params + + +DEFAULT_BODY_PARSERS = { + "application/json": parse_json, + "multipart/form-data": parse_form, + "application/x-www-form-urlencoded": parse_form, +} + + +def read_query(httprequest, routing): + """Query string params, with the `query_parser` of the route if any.""" + parser = routing.get("query_parser") or parse_query + return parser(httprequest) + + +def read_body(httprequest, routing): + """Body params, with the `body_parsers` of the route (by mimetype) if any.""" + parsers = dict(DEFAULT_BODY_PARSERS, **(routing.get("body_parsers") or {})) + parser = parsers.get(httprequest.mimetype) + if not parser: + return {} + return parser(httprequest) diff --git a/http_api_dispatcher/pyproject.toml b/http_api_dispatcher/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/http_api_dispatcher/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/http_api_dispatcher/readme/CONFIGURE.md b/http_api_dispatcher/readme/CONFIGURE.md new file mode 100644 index 00000000..5e51c61b --- /dev/null +++ b/http_api_dispatcher/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +To include the traceback and the description of any error in the responses, +set `dev_mode` in the `http_api` section of the configuration file: + + [http_api] + dev_mode = True + +The `dev_mode` key of the `base_rest` section is honored as well. +Never enable it in production. diff --git a/http_api_dispatcher/readme/CONTEXT.md b/http_api_dispatcher/readme/CONTEXT.md new file mode 100644 index 00000000..2102c9c4 --- /dev/null +++ b/http_api_dispatcher/readme/CONTEXT.md @@ -0,0 +1,35 @@ +`base_rest` (OCA/rest-framework) has been the foundation of many APIs, +including the Shopfloor apps. Over time it showed some limits: + +- it is a monolith: dispatcher, service layer, validation, + documentation and routing are bound together + and you cannot use one piece without the others +- services must be `component`s, a second registry + with its own inheritance and boot lifecycle, + which is often used only to look up a class by name +- controllers are generated at boot from the services, + making routing hard to follow and to customize + +The `fastapi` stack is a way to go for new standalone APIs, +but moving existing APIs to it means rewriting every service and schema and endpoints are not extendable models. +Plus, it pulls in more external dependencies that for many cases are not needed. + +This module is the base of a lighter stack, made of small modules +that can be used separately and extended via standard Odoo inheritance: + +- `http_api_dispatcher`: the `http_api` route type (parsing, validation hooks, JSON errors) + usable by plain Odoo controllers +- `endpoint_spec`: declare endpoints on services, for routes generators +- `http_api_cerberus`: Cerberus validation +- `openapi_doc_base`: API docs from the actual routes +- `endpoint_route_handler` generators: routes computed from the code + without storing a row per route + +Goals: + +- simplify the `base_rest` stack without moving to `fastapi` +- keep modules small and reusable +- drop the dependency on `component`, staying highly customizable + (routing keys per route, `ir.http` hooks per database) +- stay compatible with `base_rest` clients (error format, validation behavior) + to allow a gradual migration diff --git a/http_api_dispatcher/readme/CONTRIBUTORS.md b/http_api_dispatcher/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2b66303a --- /dev/null +++ b/http_api_dispatcher/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Simone Orsi \<\> diff --git a/http_api_dispatcher/readme/CREDITS.md b/http_api_dispatcher/readme/CREDITS.md new file mode 100644 index 00000000..58e4c9f7 --- /dev/null +++ b/http_api_dispatcher/readme/CREDITS.md @@ -0,0 +1,2 @@ +Error handling and request parsing are derived from `base_rest` +(OCA/rest-framework) by ACSONE SA/NV. diff --git a/http_api_dispatcher/readme/DESCRIPTION.md b/http_api_dispatcher/readme/DESCRIPTION.md new file mode 100644 index 00000000..4de55b2b --- /dev/null +++ b/http_api_dispatcher/readme/DESCRIPTION.md @@ -0,0 +1,17 @@ +Technical module providing a generic dispatcher for JSON APIs: routes of type `http_api`. + +It takes care of the plumbing common to any API endpoint: + +- parse params from the query string and the body (JSON, forms) once +- set the language from the `Accept-Language` header +- validate and convert input params and results via pluggable params + declared on the route (see `http_api_cerberus` for Cerberus schemas) +- serialize results as JSON (dates as ISO strings, decimals as floats) +- turn exceptions into JSON error responses with proper status codes +- provide hooks to extend each step via standard Odoo inheritance + +To declare endpoints on classes that are not controllers (eg: services) +whose routes are generated, see `endpoint_spec`. + +The error format and the main behaviors are compatible with `base_rest` +to ease the migration of existing APIs. diff --git a/http_api_dispatcher/readme/ROADMAP.md b/http_api_dispatcher/readme/ROADMAP.md new file mode 100644 index 00000000..65e87c59 --- /dev/null +++ b/http_api_dispatcher/readme/ROADMAP.md @@ -0,0 +1,4 @@ +- Parse lists from the query string based on the input schema + (repeated keys) instead of keeping only the last value. +- Generic call logging module hooking `_http_api_call` + (split of `rest_log` from `base_rest`). diff --git a/http_api_dispatcher/readme/USAGE.md b/http_api_dispatcher/readme/USAGE.md new file mode 100644 index 00000000..9fe04e9d --- /dev/null +++ b/http_api_dispatcher/readme/USAGE.md @@ -0,0 +1,78 @@ +## Controllers + +Use the `http_api` type on standard routes. +Extra routing keys drive the behavior of the dispatcher: + + from odoo import http + from odoo.addons.http_api_cerberus.params import CerberusParam + + class PartnerAPI(http.Controller): + + @http.route( + "/api/partner/", + type="http_api", + auth="user", + methods=["GET"], + output=CerberusParam({"id": {"type": "integer"}, "name": {"type": "string"}}), + ) + def get(self, id): + partner = http.request.env["res.partner"].browse(id) + return {"id": partner.id, "name": partner.name} + +Any value returned by the endpoint is serialized as JSON, +unless it's already a response (eg: `request.make_response`). + +Routing keys: + +| Key | Meaning | +|---|---| +| `input` | `ApiParam` validating and converting request params | +| `input_kwarg` | keyword used to pass non-dict input values (default `payload`) | +| `output` | `ApiParam` validating and converting the result | +| `prepare` | callable or list of callables receiving the `ApiCall`, run before input validation (eg: check headers, update the context) | +| `body_parsers` | `{mimetype: callable}` overriding body parsers | +| `query_parser` | callable overriding the query string parser | +| `error_map` | `[(exception classes, HTTP exception class, include description)]` evaluated before the default error mapping | +| `json_encoder` | `json.JSONEncoder` subclass for the response | +| `csrf` | as for any Odoo route (default `True`). The token is read from the `X-CSRF-Token` header, else from the `csrf_token` param of the query or the body. Set it to `False` for token based APIs | + +Params from the URL (eg: ``) always win over query and body params. +The query string and the body are parsed on first access. +An `input` param reading only the query or the body keeps the other one unparsed. + +## Params + +Subclass `odoo.addons.http_api_dispatcher.params.ApiParam` +and implement `from_params`, `to_response` and optionally `to_json_schema` +(used to generate API docs). +Raise `odoo.addons.http_api_dispatcher.errors.ApiValidationError` for invalid input: +the details of the errors are returned in the `errors` key of the response. + +## Errors + +| Exception | Status | Description in body | +|---|---|---| +| `SessionExpiredException` | 401 | no | +| `MissingError` | 404 | no | +| `AccessError`, `AccessDenied` | 403 | no | +| `UserError`, `ValidationError` | 400 | yes | +| werkzeug `HTTPException` | its own code | yes for 4xx | +| anything else | 500 | no | + +The body looks like `{"code": 400, "name": "Bad Request", "description": "..."}` +plus the content of the `rest_json_info` attribute of the exception, if any. + +## Hooks + +Each step of the pipeline is a classmethod of `ir.http` that can be extended: + +- `_http_api_prepare(call)` +- `_http_api_validate_input(call)` +- `_http_api_call(call)`: wrap it to log or profile calls +- `_http_api_validate_output(call)` +- `_http_api_make_response(call)` + +All of them receive an `ApiCall` holding the request, the endpoint, +the result and an `extra` dict for your own data. +The params are exposed lazily: `call.query`, `call.body` and `call.params` +(the merged dict, built on first access). diff --git a/http_api_dispatcher/static/description/icon.png b/http_api_dispatcher/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/http_api_dispatcher/static/description/icon.png differ diff --git a/http_api_dispatcher/tests/__init__.py b/http_api_dispatcher/tests/__init__.py new file mode 100644 index 00000000..a14583d7 --- /dev/null +++ b/http_api_dispatcher/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_errors +from . import test_call +from . import test_dispatcher diff --git a/http_api_dispatcher/tests/controllers.py b/http_api_dispatcher/tests/controllers.py new file mode 100644 index 00000000..241832cb --- /dev/null +++ b/http_api_dispatcher/tests/controllers.py @@ -0,0 +1,153 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import datetime +import decimal + +from werkzeug.exceptions import Conflict + +from odoo import exceptions, http +from odoo.http import request + +from ..errors import ApiValidationError +from ..params import ApiParam + + +class ConflictError(Exception): + pass + + +class NameParam(ApiParam): + """Require a `name` string, drop anything else.""" + + def from_params(self, call): + name = call.params.get("name") + if not isinstance(name, str) or not name: + raise ApiValidationError("BadRequest name", errors={"name": ["required"]}) + return {"name": name.strip()} + + def to_response(self, call, result): + if "greeting" not in result: + raise SystemError("Invalid Response") + return {"greeting": result["greeting"]} + + def to_json_schema(self, call, direction): + return {"type": "object"} + + +class ObjParam(NameParam): + def from_params(self, call): + return super().from_params(call)["name"] + + +class QueryParam(ApiParam): + """Read the `page` query param only: the body is never parsed.""" + + def from_params(self, call): + return {"page": call.query.get("page")} + + def to_response(self, call, result): + return result + + +def _prepare_tag(call): + call.request.update_context(http_api_test_tag=call.httprequest.headers.get("Tag")) + + +ROUTE = "/http_api_test" +DEFAULTS = {"type": "http_api", "auth": "public", "csrf": False} + + +class HttpApiTestController(http.Controller): + @http.route(f"{ROUTE}/hello", methods=["POST"], input=NameParam(), **DEFAULTS) + def hello(self, name): + return {"greeting": f"Hello {name}"} + + @http.route( + f"{ROUTE}/hello_checked", + methods=["POST"], + input=NameParam(), + output=NameParam(), + **DEFAULTS, + ) + def hello_checked(self, name): + if name == "bad": + return {"nope": 1} + return {"greeting": f"Hello {name}", "dropped": True} + + @http.route( + f"{ROUTE}/hello_obj", + methods=["POST"], + input=ObjParam(), + input_kwarg="who", + **DEFAULTS, + ) + def hello_obj(self, who): + return {"greeting": f"Hello {who}"} + + @http.route( + f"{ROUTE}/csrf", + type="http_api", + auth="public", + methods=["POST"], + csrf=True, + ) + def csrf(self, **params): + return {"params": params} + + @http.route(f"{ROUTE}/query_only", methods=["POST"], input=QueryParam(), **DEFAULTS) + def query_only(self, page): + return {"page": page} + + @http.route(f"{ROUTE}/record/", methods=["GET"], **DEFAULTS) + def record(self, id, **params): + return {"id": id, "params": params} + + @http.route(f"{ROUTE}/echo", methods=["POST", "GET"], **DEFAULTS) + def echo(self, **params): + return {"params": {k: v for k, v in params.items() if isinstance(v, str)}} + + @http.route(f"{ROUTE}/prepare", methods=["GET"], prepare=_prepare_tag, **DEFAULTS) + def prepare(self): + return {"tag": request.env.context.get("http_api_test_tag")} + + @http.route(f"{ROUTE}/lang", methods=["GET"], **DEFAULTS) + def lang(self): + return {"lang": request.env.lang} + + @http.route(f"{ROUTE}/types", methods=["GET"], **DEFAULTS) + def types(self): + return { + "date": datetime.date(2026, 1, 2), + "datetime": datetime.datetime(2026, 1, 2, 3, 4, 5), + "decimal": decimal.Decimal("1.5"), + } + + @http.route(f"{ROUTE}/raw", methods=["GET"], **DEFAULTS) + def raw(self): + return request.make_response("raw", headers=[("Content-Type", "text/plain")]) + + @http.route(f"{ROUTE}/error/", methods=["GET"], **DEFAULTS) + def error(self, kind): + errors = { + "user": exceptions.UserError("User error"), + "validation": exceptions.ValidationError("Validation error"), + "missing": exceptions.MissingError("Missing"), + "access": exceptions.AccessError("Access"), + "boom": ValueError("Boom"), + "conflict": ConflictError("Conflict"), + } + exc = errors[kind] + if kind == "user": + exc.rest_json_info = {"extra": "info"} + raise exc + + @http.route( + f"{ROUTE}/conflict", + methods=["GET"], + error_map=[(ConflictError, Conflict, True)], + **DEFAULTS, + ) + def conflict(self): + raise ConflictError("Already there") diff --git a/http_api_dispatcher/tests/test_call.py b/http_api_dispatcher/tests/test_call.py new file mode 100644 index 00000000..58f3c66c --- /dev/null +++ b/http_api_dispatcher/tests/test_call.py @@ -0,0 +1,75 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +from types import SimpleNamespace +from unittest import mock + +from werkzeug.test import EnvironBuilder + +from odoo.tests.common import BaseCase + +from ..call import ApiCall + + +def _call(path="/", method="GET", data=None, content_type=None, routing=None): + builder = EnvironBuilder( + path=path, method=method, data=data, content_type=content_type + ) + request = SimpleNamespace(httprequest=builder.get_request()) + endpoint = SimpleNamespace(routing=routing or {}) + return ApiCall(request=request, endpoint=endpoint, args={}) + + +class TestApiCall(BaseCase): + def test_query_only_does_not_parse_body(self): + """Reading the query never parses the body.""" + body = json.dumps({"a": 1}) + call = _call("/?q=x", "POST", body, "application/json") + with mock.patch( + "odoo.addons.http_api_dispatcher.call.read_body", + wraps=lambda *a, **kw: {}, + ) as read_body: + self.assertEqual(call.query, {"q": "x"}) + read_body.assert_not_called() + + def test_body_parsed_once(self): + """The body is parsed on first access and the same dict is reused.""" + body = json.dumps({"a": "1"}) + call = _call("/", "POST", body, "application/json") + with mock.patch( + "odoo.addons.http_api_dispatcher.call.read_body", + wraps=lambda *a, **kw: {"a": "1"}, + ) as read_body: + self.assertEqual(call.body, {"a": "1"}) + self.assertEqual(call.body, {"a": "1"}) + read_body.assert_called_once() + + def test_params_precedence(self): + """Params merge query, then body, then URL args: the last one wins.""" + body = json.dumps({"a": "body", "b": "body"}) + call = _call("/?a=query&c=query", "POST", body, "application/json") + call.args = {"b": "url"} + self.assertEqual(call.params, {"a": "body", "b": "url", "c": "query"}) + self.assertIs(call.params, call.params) + + def test_body_parsers_route_key(self): + """A route overrides the body parser of a mimetype via `body_parsers`.""" + body = "" + routing = {"body_parsers": {"application/xml": lambda req: {"xml": True}}} + call = _call("/", "POST", body, "application/xml", routing=routing) + self.assertEqual(call.body, {"xml": True}) + + def test_query_parser_route_key(self): + """A route overrides the query parser via `query_parser`.""" + routing = {"query_parser": lambda req: {"custom": "1"}} + call = _call("/?a=1", routing=routing) + self.assertEqual(call.query, {"custom": "1"}) + + def test_params_sync_request(self): + """The merged params are exposed on the request, for error logs.""" + call = _call("/?a=1") + self.assertFalse(hasattr(call.request, "params")) + self.assertEqual(call.params, {"a": "1"}) + self.assertEqual(call.request.params, {"a": "1"}) diff --git a/http_api_dispatcher/tests/test_dispatcher.py b/http_api_dispatcher/tests/test_dispatcher.py new file mode 100644 index 00000000..3268de84 --- /dev/null +++ b/http_api_dispatcher/tests/test_dispatcher.py @@ -0,0 +1,233 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +from unittest import mock + +from odoo.http import MISSING_CSRF_WARNING, Request +from odoo.tests.common import HttpCase, tagged +from odoo.tools import mute_logger + +from .. import call +from . import controllers # noqa: F401 # pylint: disable=unused-import + +ROUTE = "/http_api_test" +CSRF_LOGGER = "odoo.addons.http_api_dispatcher.dispatcher" +ERRORS_LOGGER = "odoo.addons.http_api_dispatcher.errors" +MUTE = ("odoo.addons.http_api_dispatcher.errors", "odoo.http") + + +@tagged("-at_install", "post_install") +class TestDispatcher(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Test controllers are loaded after the routing map might have been built + cls.env.registry.clear_cache("routing") + + def _post_json(self, path, data, **kw): + headers = dict(kw.pop("headers", {}), **{"Content-Type": "application/json"}) + return self.url_open(ROUTE + path, data=json.dumps(data), headers=headers, **kw) + + def _get(self, path, **kw): + return self.url_open(ROUTE + path, **kw) + + def test_input_validation(self): + """Input is cleaned by the param; extra keys are dropped; JSON is returned.""" + response = self._post_json("/hello", {"name": " John ", "extra": "dropped"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "application/json") + self.assertEqual(response.json(), {"greeting": "Hello John"}) + + @mute_logger(*MUTE) + def test_input_validation_error(self): + """Invalid input gives a 400 w/ the errors in the JSON body.""" + response = self._post_json("/hello", {"nope": 1}) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "code": 400, + "name": "Bad Request", + "description": "BadRequest name", + "errors": {"name": ["required"]}, + }, + ) + + def test_input_non_dict(self): + """A param can return a plain value, passed as the `input_kwarg`.""" + response = self._post_json("/hello_obj", {"name": "Jane"}) + self.assertEqual(response.json(), {"greeting": "Hello Jane"}) + + def test_output_validation(self): + """A valid result goes through the output param unchanged.""" + response = self._post_json("/hello_checked", {"name": "Jane"}) + self.assertEqual(response.json(), {"greeting": "Hello Jane"}) + + @mute_logger(*MUTE) + def test_output_validation_error(self): + """An invalid result gives a generic 500, without details.""" + response = self._post_json("/hello_checked", {"name": "bad"}) + self.assertEqual(response.status_code, 500) + self.assertEqual( + response.json(), {"code": 500, "name": "Internal Server Error"} + ) + + def test_csrf_missing(self): + """A CSRF protected route refuses requests w/o token (400) and logs why.""" + with ( + mock.patch.object(Request, "validate_csrf", return_value=False), + self.assertLogs(CSRF_LOGGER, "WARNING") as warnings, + self.assertLogs(ERRORS_LOGGER, "ERROR") as errors, + ): + response = self._post_json("/csrf", {"a": "1"}) + self.assertEqual(response.status_code, 400) + self.assertEqual( + [rec.getMessage() for rec in warnings.records], + [MISSING_CSRF_WARNING % "/http_api_test/csrf"], + ) + # The url depends on the test server: only the rest is fixed + self.assertRegex( + errors.records[0].getMessage(), + r"^RESTFULL call to url \S+/http_api_test/csrf with method POST " + r"and params \{\} raise the following error " + r"400 Bad Request: Session expired \(invalid CSRF token\)$", + ) + self.assertEqual(len(errors.records), 1) + + def test_csrf_header(self): + """The `X-CSRF-Token` header is the token checked on the request.""" + with mock.patch.object( + Request, "validate_csrf", side_effect=lambda t: t == "good" + ): + response = self._post_json( + "/csrf", {"a": "1"}, headers={"X-CSRF-Token": "good"} + ) + self.assertEqual(response.json(), {"params": {"a": "1"}}) + + def test_csrf_body(self): + """Without header, the token is read from the body and kept out of params.""" + with mock.patch.object( + Request, "validate_csrf", side_effect=lambda t: t == "good" + ): + response = self._post_json("/csrf", {"a": "1", "csrf_token": "good"}) + self.assertEqual(response.json(), {"params": {"a": "1"}}) + + def test_query_only_param(self): + """A param reading only the query never parses the body.""" + with mock.patch.object(call, "read_body", side_effect=AssertionError): + response = self.url_open( + ROUTE + "/query_only?page=2", + data=json.dumps({"a": "1"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.json(), {"page": "2"}) + + @mute_logger(*MUTE) + def test_invalid_json(self): + """Malformed JSON gives a 400 'Invalid JSON data'.""" + response = self.url_open( + ROUTE + "/hello", + data="{nope", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid JSON data", response.json()["description"]) + + def test_path_and_query(self): + """Path and query params are merged; the path wins on conflicts.""" + response = self._get("/record/12?a=1&b=two&id=99") + self.assertEqual(response.json(), {"id": 12, "params": {"a": "1", "b": "two"}}) + + def test_form_body(self): + """Form-encoded body params are parsed like JSON.""" + response = self.url_open(ROUTE + "/echo", data={"a": "1", "b": "2"}) + self.assertEqual(response.json(), {"params": {"a": "1", "b": "2"}}) + + def test_json_body_and_query(self): + """JSON body and query params are merged.""" + response = self._post_json("/echo?q=x", {"a": "1"}) + self.assertEqual(response.json(), {"params": {"a": "1", "q": "x"}}) + + def test_method_not_allowed(self): + """A route only accepts its declared methods (405 otherwise).""" + response = self.url_open(ROUTE + "/record/1", data={"a": 1}) + self.assertEqual(response.status_code, 405) + + def test_prepare(self): + """A hook can read request headers before the handler runs.""" + response = self._get("/prepare", headers={"Tag": "foo"}) + self.assertEqual(response.json(), {"tag": "foo"}) + + def test_types(self): + """Dates, datetimes and decimals are serialized as ISO strings and floats.""" + response = self._get("/types") + self.assertEqual( + response.json(), + {"date": "2026-01-02", "datetime": "2026-01-02T03:04:05", "decimal": 1.5}, + ) + + def test_raw_response(self): + """A raw response is returned as is, w/ its own content type.""" + response = self._get("/raw") + self.assertEqual(response.text, "raw") + self.assertTrue(response.headers["Content-Type"].startswith("text/plain")) + + def test_lang(self): + """The language follows `Accept-Language`, falling back to `en_US`.""" + self.env["res.lang"]._activate_lang("fr_FR") + cases = ( + ("fr-BE,fr;q=0.9", "fr_FR"), + ("fr-FR", "fr_FR"), + ("de-DE,de;q=0.9", "en_US"), + ("de-DE,fr;q=0.5", "fr_FR"), + ) + for header, expected in cases: + response = self._get("/lang", headers={"Accept-Language": header}) + self.assertEqual(response.json(), {"lang": expected}, header) + + @mute_logger(*MUTE) + def test_errors(self): + """Each error kind maps to its status code; no traceback in the body.""" + cases = ( + ("user", 400, {"description": "User error", "extra": "info"}), + ("validation", 400, {"description": "Validation error"}), + ("missing", 404, {}), + ("access", 403, {}), + ("boom", 500, {}), + ("conflict", 500, {}), + ) + for kind, code, extra in cases: + response = self._get(f"/error/{kind}") + self.assertEqual(response.status_code, code, kind) + body = response.json() + self.assertEqual(body["code"], code, kind) + self.assertNotIn("traceback", body) + for key, value in extra.items(): + self.assertEqual(body[key], value, kind) + if not extra: + self.assertNotIn("description", body, kind) + + @mute_logger(*MUTE) + def test_error_map(self): + """A custom error map turns an exception into the mapped status.""" + response = self._get("/conflict") + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["description"], "Already there") + + @mute_logger(*MUTE) + def test_dev_mode(self): + """In dev mode the error body includes the traceback.""" + + def get_misc(section, key, default=None): + return section == "base_rest" and key == "dev_mode" + + with mock.patch( + "odoo.addons.http_api_dispatcher.errors.config.get_misc", + side_effect=get_misc, + ): + response = self._get("/error/boom") + body = response.json() + self.assertIn("ValueError: Boom", body["traceback"]) + self.assertIn("description", body) diff --git a/http_api_dispatcher/tests/test_errors.py b/http_api_dispatcher/tests/test_errors.py new file mode 100644 index 00000000..f4278f2c --- /dev/null +++ b/http_api_dispatcher/tests/test_errors.py @@ -0,0 +1,64 @@ +# Copyright 2026 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +from unittest import mock + +from werkzeug.exceptions import Conflict, NotFound +from werkzeug.wrappers import Response + +from odoo.exceptions import UserError +from odoo.tests.common import BaseCase + +from ..errors import ApiValidationError, ErrorMapper, is_dev_mode + + +class TestErrors(BaseCase): + def test_no_request(self): + """Errors map to JSON responses without a request bound.""" + response = ErrorMapper().to_response(UserError("Nope")) + self.assertEqual(response.status_code, 400) + self.assertEqual( + json.loads(response.get_data()), + {"code": 400, "name": "Bad Request", "description": "Nope"}, + ) + + def test_validation_error(self): + """A validation error gives a 400 w/ its errors.""" + exc = ApiValidationError("Invalid", errors={"a": ["required"]}) + body = json.loads(ErrorMapper().to_response(exc).get_data()) + self.assertEqual(body["errors"], {"a": ["required"]}) + self.assertEqual(body["code"], 400) + + def test_http_exception(self): + """A werkzeug HTTP exception keeps its status code.""" + response = ErrorMapper().to_response(NotFound()) + self.assertEqual(response.status_code, 404) + self.assertEqual(json.loads(response.get_data())["code"], 404) + + def test_explicit_response(self): + """An exception carrying its own response is returned as is.""" + exc = NotFound(response=Response("custom", status=418)) + self.assertIs(ErrorMapper().to_response(exc), exc) + + def test_routing_error_map(self): + """A route's error map maps exceptions to their status.""" + mapper = ErrorMapper.for_routing({"error_map": [(KeyError, Conflict, False)]}) + response = mapper.to_response(KeyError("x")) + self.assertEqual(response.status_code, 409) + self.assertNotIn("description", json.loads(response.get_data())) + + def test_dev_mode_flag(self): + """Dev mode is on only when its config flag is true.""" + cases = ( + ({}, False), + ({"http_api": {"dev_mode": "False"}}, False), + ({"http_api": {"dev_mode": "True"}}, True), + ({"base_rest": {"dev_mode": "1"}}, True), + ) + for misc, expected in cases: + with mock.patch.dict( + "odoo.addons.http_api_dispatcher.errors.config.misc", misc, clear=True + ): + self.assertEqual(is_dev_mode(), expected, misc) diff --git a/http_api_dispatcher/utils.py b/http_api_dispatcher/utils.py new file mode 100644 index 00000000..0e9ee329 --- /dev/null +++ b/http_api_dispatcher/utils.py @@ -0,0 +1,20 @@ +# Copyright 2018 ACSONE SA/NV +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""Helpers for API endpoints. + +`to_int` is taken from `base_rest.components.service` (OCA/rest-framework). +""" + + +def to_int(val): + """Convert to integer values coming as strings or floats. + + JS clients might send numbers as floats + and query string values are always strings. + """ + if isinstance(val, int): + return val + if val: + return int(float(val)) + return None