Skip to Content

Свій тип маршруту в Odoo: Dispatcher для вебхуків

Задача: прийняти вхідний вебхук від маркетплейсу, служби доставки чи шлюзу так, щоб Odoo не вимагав Content-Type: application/json, не віддавав traceback і відповідав нормальними HTTP-кодами. Стандартні type='http' і type='json' цього не вміють. З 16.0 тип маршруту можна зареєструвати самому, і диспетчер для цього займає близько сорока рядків.

Чому не стандартні типи

Туторіали, наприклад Cybrosys про RPC в Odoo 18, радять type='json'. Для власного фронтенду це правильно. Вебхук шле своє тіло, а не конверт JSON-RPC (прогін на 18.0 і 19.0, маршрут з аргументами order_id і status):

curl -s -X POST $U/t/json -H 'Content-Type: application/json' \
  -d '{"order_id":17,"status":"paid"}'
{"jsonrpc": "2.0", "id": null, "result": {"got": [null, null]}}

HTTP 200, аргументи загублені: диспетчер бере їх лише з ключа params. Виняток у такому маршруті теж іде з кодом 200, а error.data.debug містить повний traceback. Форма замість JSON дає 400 в 18.0 і 415 в 19.0. На type='http' доводиться вимикати CSRF і самому розбирати тіло, а 500 віддає HTML-сторінку werkzeug.

Як реєструється тип маршруту

Весь механізм це словник _dispatchers і клас Dispatcher. Підклас потрапляє в словник у момент оголошення:

_dispatchers = {}

class Dispatcher(ABC):
    routing_type: str

    @classmethod
    def __init_subclass__(cls):
        super().__init_subclass__()
        _dispatchers[cls.routing_type] = cls

Odoo дивиться на цей словник у двох місцях. @http.route(type=...) перевіряє тип у момент декорування, тож диспетчер має бути імпортований раніше за контролер, інакше модуль не завантажиться з AssertionError (прогнано на 16.0, 18.0, 19.0). А на кожен запит _set_request_dispatcher бере клас за типом маршруту і питає is_compatible_with(request); відмова дає 400 (16-18) або 415 (19.0). Далі йдуть pre_dispatch, dispatch, post_dispatch, а будь-який виняток потрапляє в handle_error.

Важливо! routing_type є ключем глобального словника: повторне значення мовчки замінить клас (так 'json' замінив би стандартний). Результат endpoint'а для нестандартного типу ніхто не обгортає в Response, це робота dispatch. А до матчингу маршруту діє HttpDispatcher, тому 404 і 405 приходять HTML-сторінкою навіть для вашого типу.

Що змінювалось між версіями

  • 14.0 і 15.0 — реєстру немає, є класи JsonRequest і HttpRequest. «З 16» підтверджено кодом: Dispatcher і _dispatchers з'являються в 16.0.
  • 16.0-19.0 — контракт той самий, п'ять методів. Диспетчер нижче без змін працює на всіх чотирьох версіях (прогін).
  • 17.0 додав у pre_dispatch ключ маршруту max_content_length, в 18.0 він може бути callable.
  • 19.0 — атрибут mimetypes, type='json' лишився псевдонімом jsonrpc, з'явився вбудований json2 з реальними HTTP-кодами. Приймає він лише application/json або порожнє тіло, тож для вебхука з довільним Content-Type не підходить.

Мінімальний диспетчер

Тип webhook приймає будь-який Content-Type, відповідає своїм JSON і своїми кодами, деталей помилки не показує.

import json

from werkzeug.exceptions import HTTPException

from odoo import http
from odoo.exceptions import AccessDenied, AccessError, MissingError, UserError
from odoo.http import Dispatcher


class WebhookDispatcher(Dispatcher):
    routing_type = 'webhook'

    @classmethod
    def is_compatible_with(cls, request):
        return True

    def dispatch(self, endpoint, args):
        raw = self.request.httprequest.get_data()
        try:
            payload = json.loads(raw) if raw else {}
        except ValueError:
            return self._error(400, 'invalid_json', 'Body is not valid JSON')
        self.request.params = dict(args, payload=payload)
        if self.request.db:
            result = self.request.registry['ir.http']._dispatch(endpoint)
        else:
            result = endpoint(**self.request.params)
        if isinstance(result, http.Response):
            return result
        return self.request.make_json_response({'ok': True, 'result': result})

    def handle_error(self, exc):
        if isinstance(exc, HTTPException):
            code = exc.name.lower().replace(' ', '_')
            return self._error(exc.code, code, exc.description)
        if isinstance(exc, (AccessDenied, AccessError)):
            return self._error(403, 'forbidden', 'Access denied')
        if isinstance(exc, MissingError):
            return self._error(404, 'not_found', str(exc))
        if isinstance(exc, UserError):
            return self._error(422, 'rejected', str(exc))
        return self._error(500, 'internal_error', 'Internal Server Error')

    def _error(self, status, code, message):
        body = {'ok': False, 'error': code, 'message': message}
        return self.request.make_json_response(body, status=status)


def webhook_route(route, auth='public', **kw):
    return http.route(
        route, type='webhook', auth=auth, methods=['POST'],
        save_session=False, **kw)

is_compatible_with повертає True, тож перевірка типу вмісту зникає, а get_data() дає сирі байти для підпису HMAC. Порядок isinstance важливий, бо AccessDenied, AccessError і MissingError усі наслідують UserError. Traceback у відповідь не потрапляє, бо handle_error його не серіалізує (прогін: у логу він є, у відповіді його немає). Гілка if self.request.db повторює стандартні диспетчери за кодом, на живій базі я її не запускав.

Важливо! З --dev=werkzeug на нестандартному типі виняток піднімається до debugger-а werkzeug, і traceback потрапляє у відповідь. Так у 16.0, 18.0 і 19.0; у 17.0 такої перевірки немає (читання коду).

Підключення

Контролер підключаємо через хелпер, а імпорт диспетчера в controllers/__init__.py ставимо першим.

from odoo import http
from odoo.exceptions import UserError

from .dispatcher import webhook_route


class Hooks(http.Controller):

    @webhook_route('/hook/orders')
    def orders(self, payload):
        if not isinstance(payload, dict):
            raise UserError('JSON object expected')
        return {'keys': sorted(payload)}

save_session=False прибирає Set-Cookie, а csrf=False не потрібен: CSRF перевіряє тільки HttpDispatcher. Прогін на 16.0, 17.0, 18.0 і 19.0 без бази дав такі відповіді (маршрути з auth='none', інакше Odoo без бази їх не віддає). Варіант з auth='public' і базою я лише прочитав у коді, живим запуском не перевіряв.

Content-Type: application/json, {"order_id":17,"status":"paid"}  -> 200 {"ok": true, "result": {"keys": ["order_id", "status"]}}
Content-Type: text/plain,       {"order_id":17}                  -> 200 {"ok": true, "result": {"keys": ["order_id"]}}
тіло {bad                                                        -> 400 {"ok": false, "error": "invalid_json", ...}
тіло [1]                                                         -> 422 {"ok": false, "error": "rejected", "message": "JSON object expected"}
виняток ValueError у обробнику                                   -> 500 {"ok": false, "error": "internal_error", "message": "Internal Server Error"}
Свій тип маршруту в Odoo: Dispatcher для вебхуків
admin 23 жовтня 2026 р.
Поділитися цією публікацією
Теги
Архів