Задача: клієнту треба 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-снепет виходить довше, ніж написати
нормальний контролер на десяток рядків.