Skip to Content

Як віддати REST-ендпоінт з Odoo, не написавши жодного контролера

Задача: клієнту треба API для довільної моделі — партнери, власний довідник, що завгодно — без написання http.Controller під кожен ресурс і без ручного ведення Swagger-специфікації поруч із кодом, яка неминуче розходиться з реальністю. У нас для цього є пара модулів: kw_api_custom_endpoint (сам механізм) і kw_api_swagger (документація до нього), обидва поверх базового kw_api.

Що зберігає опис ендпоінта

Ендпоінт — це запис моделі kw.api.custom.endpoint, не Python-клас. У ньому модель, маршрут, дозволені операції і права:

class CustomEndpoint(models.Model):
    _name = 'kw.api.custom.endpoint'
    _inherit = ['kw.http.request.log.source.mixin', ]

    api_name = fields.Char(required=True, )          # /kw_api/custom/<api_name>
    route_path = fields.Char()                        # опційний альтернативний шлях
    model_id = fields.Many2one(comodel_name='ir.model', required=True, )
    kind = fields.Selection(default='fields', selection=[
        ('fields', 'Fields'), ('function', 'Function'), ('code', 'Code')])

    is_list_enabled = fields.Boolean(default=True, )
    is_get_enabled = fields.Boolean(default=True, )
    is_create_enabled = fields.Boolean()
    is_update_enabled = fields.Boolean()
    is_delete_enabled = fields.Boolean()

    is_token_required = fields.Boolean(default=True, )
    is_api_key_required = fields.Boolean(default=True, )

    respect_acl = fields.Boolean(default=True, groups='base.group_system', )
    run_as_user_id = fields.Many2one(
        comodel_name='res.users', groups='base.group_system', )

    domain = fields.Char()
    field_ids = fields.One2many(
        comodel_name='kw.api.custom.endpoint.field', inverse_name='endpoint_id', )

Поля моделі, які треба віддати назовні, описуються окремими записами kw.api.custom.endpoint.field — там же налаштовується вхідна/вихідна назва поля, чи можна поле міняти (is_changeable), чи можна за ним шукати (is_searchable) і як показувати зв'язані записи (relation_display: pure_id / id_only / id_name / id_date / id_name_date / single_field / data_endpoint з вкладеним ендпоінтом). Тобто вся конфігурація ресурсу — це рядки в базі, а не код.

Один контролер на всі ендпоінти

Класичний Odoo-контролер — це @http.route на кожен ресурс: п'ять ресурсів — п'ять класів. Тут інакше: один контролер обробляє всі custom-ендпоінти одразу, а api_name з URL визначає, який запис kw.api.custom.endpoint відповідає за запит:

class CustomEndpointController(http.Controller):

    @kw_api_route(
        methods=['GET', 'POST', 'DELETE', 'OPTIONS'],
        route=['/kw_api/custom/<string:api_name>',
               '/kw_api/custom/<string:api_name>/<string:obj_id>'],
        kw_dynamic_auth=True, kw_defer_log=True, kw_get_json=False, )
    def kw_api_custom_response(self, ctx, api_name, obj_id=False, **kw):
        endpoint = request.env['kw.api.custom.endpoint'].sudo().search(
            [('api_name', '=', api_name)], limit=1)
        if not endpoint:
            raise KwApiError('not_found', '404: Not Found', http_status=404)
        ...
        if request.httprequest.method == 'POST':
            self._check_operation(endpoint, 'update' if obj_id else 'create')
            response = endpoint.change(ctx=ctx, obj_id=obj_id, **kw)
        elif request.httprequest.method == 'DELETE':
            self._check_operation(endpoint, 'delete')
            response = endpoint.delete(ctx=ctx, obj_id=obj_id, **kw)
        else:
            self._check_operation(endpoint, 'get' if obj_id else 'list')
            response = endpoint.response(ctx=ctx, obj_id=obj_id, **kw)
        return response

Ось і межа узагальненості: маршрутизація на рівні HTTP — одна пара шляхів (/kw_api/custom/<api_name> і /kw_api/custom/<api_name>/<obj_id>) на всі ендпоінти. Далі диспетчеризація йде не по URL, а по значенню api_name, знайденому в базі. Це дешевше в підтримці, ніж контролер на ресурс, але й означає, що будь-яка нестандартна форма маршруту (кілька динамічних сегментів, вкладені ресурси на кшталт /partners/<id>/orders) сюди вже не лягає — про це нижче, в межах застосовності.

Хто виконує запит

Ну ок, це і є головне питання будь-якого «API без коду». Дозволи керуються двома незалежними речами: чи потрібен токен/ключ узагалі (is_token_required, is_api_key_required) і від чийого імені реально виконується запит (respect_acl, run_as_user_id):

def api_user_id(self, ctx):
    self.ensure_one()
    if self.respect_acl and ctx.auth.user:
        return ctx.auth.user.id
    if self.run_as_user_id:
        return self.run_as_user_id.id
    if not self.respect_acl:
        return SUPERUSER_ID
    return self.env.ref('base.public_user').id

Порядок пріоритету такий: якщо ACL поважаються і є автентифікований користувач (з токена) — виконуємо від нього, і Odoo сам застосує ACL та record rules, як для будь- якого іншого запиту. Якщо користувача немає, але заданий run_as_user_id — виконуємо від технічного користувача (звичний спосіб для сервісних інтеграцій без токена кінцевого юзера). Якщо ACL прямо вимкнені (respect_acl=False) — падаємо в SUPERUSER_ID, це той самий «легасі-режим», який обходить усі перевірки прав. Якщо нічого з цього не спрацювало — публічний користувач, зі своїми (мінімальними) правами.

Неправильно: вимикати Respect ACL, бо «швидше запрацює». Тоді кожне поле, яке ви додали в field_ids, стає доступним будь-кому з валідним токеном чи ключем — Odoo просто не перевіряє.

Правильно: я лишаю respect_acl=True (це і є значення за замовчуванням) і видаю токени через /kw_api/auth/token — тоді права визначаються так само, як у веб- інтерфейсі: групами й record rules реального користувача.

Важливо! Обидва поля, що впливають на безпеку — respect_acl і run_as_user_id, — доступні лише групі base.group_system на рівні самого поля моделі: рядовий користувач, якому дали право створювати ендпоінти, фізично не може виставити respect_acl=False навіть через API, create/write кинуть AccessError. Те саме стосується code_snippet — його теж бачить і редагує тільки системний адміністратор.

За замовчуванням усе закрито: is_token_required=True, is_api_key_required=True, respect_acl=True — новий ендпоінт без жодних додаткових налаштувань вимагає токен, і цей токен виконується під реальними правами користувача. Додатково kw.api.key можна прив'язати до конкретного списку ендпоінтів через custom_endpoint_ids — ключ, скомпрометований в одній інтеграції, не відкриє решту API.

Відповідь і помилки

Успішна відповідь іде через ctx.response.ok(data), помилки — через виняток KwApiError('код', 'повідомлення', http_status=...), який ловить диспетчер:

ODOO_EXCEPTION_MAP = (
    ((AccessDenied, AccessError), 'access_error', 403),
    ((MissingError,), 'not_found', 404),
    ((UserError,), 'user_error', 400),
)

Стандартні винятки Odoo (AccessError, MissingError, UserError) конвертуються в той самий формат {"error": {"code": ..., "message": ...}} автоматично — окремо ловити їх у кожному ендпоінті не треба. Непередбачений виняток згортається в 500 з повідомленням Internal Server Error, без стек-трейсу назовні; сама подія потрапляє в лог ендпоінта (kw.http.request.log) з повним repr(exc).

Swagger без жодного рядка специфікації вручну

kw_api_swagger не тримає окремий YAML/JSON зі специфікацією — він будує його на льоту з тих самих записів kw.api.custom.endpoint і kw.api.custom.endpoint.field:

SCALAR_SCHEMA = {
    'char': {'type': 'string'},
    'integer': {'type': 'integer'},
    'float': {'type': 'number', 'format': 'float'},
    'boolean': {'type': 'boolean'},
    'date': {'type': 'string', 'format': 'date'},
    'datetime': {'type': 'string', 'format': 'date-time'},
    # ...і ще кілька простих типів: text, html, monetary, many2one_reference
}

def _oa_record_schema(self, endpoint, seen, top=True):
    if endpoint.id in seen:
        return {'type': 'object'}
    seen = seen | {endpoint.id}
    properties = {'id': {'type': 'integer'}}
    if top:
        properties['write_date'] = dict(WRITE_DATE_SCHEMA)
    for f in endpoint.field_ids:
        properties[f.outbound_name] = self._oa_field_output_schema(
            f, seen)
    return {'type': 'object', 'properties': properties}

Тип поля в схемі береться з ttype реального поля Odoo-моделі (ir.model.fields), а не з якоїсь окремої мапи, яку треба синхронізувати руками. Додали поле в ендпоінт — воно з'явилось у /kw_api/swagger/swagger.json при наступному запиті, без релізу і без правки документації. Рекурсивна побудова схеми захищена від циклів через set seen: якщо data_endpoint-поле веде на вже відвіданий ендпоінт (у т.ч. по колу самих на себе посилаючихся ендпоінтів), у відповідь підставляється порожній {"type": "object"} замість нескінченної рекурсії; для кореневого запису схема додатково включає write_date. Той самий принцип для параметрів пошуку: якщо поле позначене is_searchable, у Swagger автоматично з'являється query-параметр із описом дозволених операторів (eq, gt, ilike тощо) — вони теж не хардкодяться під конкретний ендпоінт, а виводяться з OPERATOR_MAP.

Розглянемо ще одну деталь: доступ до самого Swagger UI відрізняється від доступу до API. /kw_api/swagger/ захищений як звичайна сторінка Odoo — auth='user' плюс перевірка групи kw_api.group_kw_api_user, тобто сесією залогіненого користувача, а не API-токеном чи ключем. Це дві різні системи авторизації для двох різних споживачів: розробник дивиться документацію у своєму браузері, інтеграція ходить в API окремо.

Межа застосовності

«API без коду» завжди має стелю. У цій реалізації вона проходить так:

  • Тільки GET, POST, DELETE. PUT/PATCH нема — і створення, і оновлення йдуть через POST (наявність obj_id в URL вирішує, що саме відбудеться).
  • Маршрут — тільки статичні сегменти. route_path перевіряється регуляркою, що не пускає динамічні конвертери Odoo (<int:id>), і забороняє префікси /web, /odoo, /website, /mail тощо — щоб не перехопити чужий маршрут.
  • kind='code' — це пісочниця safe_eval, не повноцінний Python. Знімку доступні фіксований набір імен (env, model, data, json, datetime, KwApiError...), довільний import чи звернення поза цим контекстом не пройде.
  • Кастомна логіка пошуку все одно вимагає коду на моделі. Якщо модель реалізує метод kw_api_search, ендпоінт піде через нього і віддасть контроль над limit/ offset, — а такий метод пишеться в Python-модулі моделі, конструктором ендпоінтів його не описати.
  • Swagger-схема для kind='function' і kind='code' — узагальнена. Точна схема з переліком полів будується тільки для kind='fields'; для функції чи code-снепету Swagger чесно показує {"type": "object"} без деталізації, бо звідки взяти форму довільного Python-результату.
  • Джерелом ендпоінта не може бути transient-модель. model_id обмежений доменом [('transient', '=', False)] — майстри (TransientModel) для конструктора недоступні.
  • Вкладені inbound-зв'язки мають стелю глибини. Побудова вхідних зв'язків обмежена константою MAX_INBOUND_DEPTH=5 — на шостому рівні вкладеності конструктор далі не йде.

Коли ці межі стають тісними — множинні транзакції на кількох моделях, нестандартний формат маршруту, стрімінг відповіді — я в такому разі просто пишу окремий http.Controller: викручуватись через code-снепет виходить довше, ніж написати нормальний контролер на десяток рядків.

Як віддати REST-ендпоінт з Odoo, не написавши жодного контролера
KitWorks, Volodymyr Karabanov 25 липня 2026 р.
Поділитися цією публікацією
Теги
Архів