From f00fde42e8eefc8aca588133d3eb781adad6e136 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 21 Oct 2026 09:21:41 +0200 Subject: [PATCH] [ADD] http_api_dispatcher: generic dispatcher for JSON APIs Route type `http_api`: generic dispatcher for HTTP routes speaking JSON. * parse query string and body (JSON, forms) once * set lang from `Accept-Language` * validate/convert input and output via pluggable params declared on the route * serialize results as JSON * map exceptions to JSON error responses (format compatible w/ base_rest) * `ir.http` hooks to extend each step of the pipeline (`_http_api_*`) Lazy params: `ApiCall` exposes `query`, `body` and `params` as cached properties, parsed on first access. `params` merges them: query, then body, then URL args. CSRF: the token is read from the `X-CSRF-Token` header first; the query and the body are parsed only when the header is missing. The `csrf_token` key is transport data: it is never part of the params of the endpoint. Parsers: routes override the body parsers via `body_parsers` (mimetype map, formerly `parsers`) and the query parser via `query_parser`. Params: `ApiParam.from_params(call)` receives the `ApiCall` and reads `call.params`, `call.query` or `call.body` as it needs. A param reading only the query never parses the body. Errors: `dev_mode` is configured in the `[http_api]` section. --- http_api_dispatcher/README.rst | 1 + http_api_dispatcher/__init__.py | 2 + http_api_dispatcher/__manifest__.py | 14 ++ http_api_dispatcher/call.py | 82 ++++++ http_api_dispatcher/dispatcher.py | 117 +++++++++ http_api_dispatcher/encoder.py | 29 +++ http_api_dispatcher/errors.py | 195 +++++++++++++++ http_api_dispatcher/models/__init__.py | 1 + http_api_dispatcher/models/ir_http.py | 86 +++++++ http_api_dispatcher/params.py | 48 ++++ http_api_dispatcher/parsers.py | 70 ++++++ http_api_dispatcher/pyproject.toml | 3 + http_api_dispatcher/readme/CONFIGURE.md | 8 + http_api_dispatcher/readme/CONTEXT.md | 35 +++ http_api_dispatcher/readme/CONTRIBUTORS.md | 1 + http_api_dispatcher/readme/CREDITS.md | 2 + http_api_dispatcher/readme/DESCRIPTION.md | 17 ++ http_api_dispatcher/readme/ROADMAP.md | 4 + http_api_dispatcher/readme/USAGE.md | 78 ++++++ .../static/description/icon.png | Bin 0 -> 9455 bytes http_api_dispatcher/tests/__init__.py | 3 + http_api_dispatcher/tests/controllers.py | 153 ++++++++++++ http_api_dispatcher/tests/test_call.py | 75 ++++++ http_api_dispatcher/tests/test_dispatcher.py | 233 ++++++++++++++++++ http_api_dispatcher/tests/test_errors.py | 64 +++++ http_api_dispatcher/utils.py | 20 ++ 26 files changed, 1341 insertions(+) create mode 100644 http_api_dispatcher/README.rst create mode 100644 http_api_dispatcher/__init__.py create mode 100644 http_api_dispatcher/__manifest__.py create mode 100644 http_api_dispatcher/call.py create mode 100644 http_api_dispatcher/dispatcher.py create mode 100644 http_api_dispatcher/encoder.py create mode 100644 http_api_dispatcher/errors.py create mode 100644 http_api_dispatcher/models/__init__.py create mode 100644 http_api_dispatcher/models/ir_http.py create mode 100644 http_api_dispatcher/params.py create mode 100644 http_api_dispatcher/parsers.py create mode 100644 http_api_dispatcher/pyproject.toml create mode 100644 http_api_dispatcher/readme/CONFIGURE.md create mode 100644 http_api_dispatcher/readme/CONTEXT.md create mode 100644 http_api_dispatcher/readme/CONTRIBUTORS.md create mode 100644 http_api_dispatcher/readme/CREDITS.md create mode 100644 http_api_dispatcher/readme/DESCRIPTION.md create mode 100644 http_api_dispatcher/readme/ROADMAP.md create mode 100644 http_api_dispatcher/readme/USAGE.md create mode 100644 http_api_dispatcher/static/description/icon.png create mode 100644 http_api_dispatcher/tests/__init__.py create mode 100644 http_api_dispatcher/tests/controllers.py create mode 100644 http_api_dispatcher/tests/test_call.py create mode 100644 http_api_dispatcher/tests/test_dispatcher.py create mode 100644 http_api_dispatcher/tests/test_errors.py create mode 100644 http_api_dispatcher/utils.py 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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 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