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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions http_api_dispatcher/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
waiting the bot :)
2 changes: 2 additions & 0 deletions http_api_dispatcher/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import dispatcher
from . import models
14 changes: 14 additions & 0 deletions http_api_dispatcher/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2026 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

{
"name": "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"],
}
82 changes: 82 additions & 0 deletions http_api_dispatcher/call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# 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: `<int:id>`)
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
117 changes: 117 additions & 0 deletions http_api_dispatcher/dispatcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Copyright 2018 ACSONE SA/NV
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# 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
29 changes: 29 additions & 0 deletions http_api_dispatcher/encoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2018 ACSONE SA/NV
# Copyright 2026 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# 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)
Loading
Loading