Skip to content

Commit b51909f

Browse files
[IMP] runbot: implement public api mixin
Until now api routes on runbot have been created through custom website pages in production. We want to unify the API by making a 'public' api, inspired by the way `web_search_read` works. This commit adds: - A route to list all publicly available models - A route to do a read on a public model - A route to fetch the publicly available specification for a model - A public model mixin that provides all the tools required to support the above mentionned routes. The mixin adds the ability to add the `public` attribute on fields. Any field marked as public can then be publicly queried through the controller. Relational fields work in a nested manner (`fields` key in the field's sub-specification) (up to a depth of 10). The public api does not allow going through a relationship back and front (parent->child->parent is NOT allowed). Because we are based on `web_search_read`, we heavily focus on validating the specification, for security reasons, and offset the load of reading to the `web_read` function (we currently don't provide limit metadata).
1 parent 3cf9dd6 commit b51909f

File tree

12 files changed

+1056
-0
lines changed

12 files changed

+1056
-0
lines changed

runbot/controllers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from . import frontend
44
from . import hook
55
from . import badge
6+
from . import public_api

runbot/controllers/public_api.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import json
2+
3+
from werkzeug.exceptions import BadRequest, Forbidden
4+
5+
from odoo.exceptions import AccessError
6+
from odoo.http import Controller, request, route
7+
from odoo.tools import mute_logger
8+
9+
from odoo.addons.runbot.models.public_model_mixin import PublicModelMixin
10+
11+
12+
class PublicApi(Controller):
13+
14+
@mute_logger('odoo.addons.base.models.ir_model') # We don't care about logging acl errors
15+
def _get_model(self, model: str) -> PublicModelMixin:
16+
"""
17+
Returns the model from a model string.
18+
19+
Raises the appropriate exception if:
20+
- The model does not exist
21+
- The model is not a public model
22+
- The current user can not read the model
23+
"""
24+
pool = request.env.registry
25+
try:
26+
Model = pool[model]
27+
except KeyError:
28+
raise BadRequest('Unknown model')
29+
if not issubclass(Model, pool['runbot.public.model.mixin']):
30+
raise BadRequest('Unknown model')
31+
Model = request.env[model]
32+
Model.check_access('read')
33+
if not Model._api_request_allow_direct_access():
34+
raise Forbidden('This model does not allow direct access')
35+
return Model
36+
37+
@route('/runbot/api/models', auth='public', methods=['GET'], readonly=True)
38+
def models(self):
39+
models = []
40+
for model in request.env.keys():
41+
try:
42+
models.append(self._get_model(model))
43+
except (BadRequest, AccessError, Forbidden):
44+
pass
45+
return request.make_json_response(
46+
[Model._name for Model in models]
47+
)
48+
49+
@route('/runbot/api/<model>/read', auth='public', methods=['POST'], readonly=True, csrf=False)
50+
def read(self, *, model: str):
51+
Model = self._get_model(model)
52+
required_keys = Model._api_request_required_keys()
53+
allowed_keys = Model._api_request_allowed_keys()
54+
try:
55+
data = request.get_json_data()
56+
except json.JSONDecodeError:
57+
raise BadRequest('Invalid payload, missing or malformed json')
58+
if not isinstance(data, dict):
59+
raise BadRequest('Invalid payload, should be a dict.')
60+
if (missing_keys := required_keys - set(data.keys())):
61+
raise BadRequest(f'Invalid payload, missing keys: {", ".join(missing_keys)}')
62+
if (unknown_keys := set(data.keys()) - allowed_keys):
63+
raise BadRequest(f'Invalid payload, unknown keys: {", ".join(unknown_keys)}')
64+
if Model._api_request_requires_project():
65+
if not isinstance(data['project_id'], int):
66+
raise BadRequest('Invalid project_id, should be an int')
67+
# This is an additional layer of protection for project_id
68+
project = request.env['runbot.project'].browse(data['project_id']).exists()
69+
if not project:
70+
raise BadRequest('Unknown project_id')
71+
project.check_access('read')
72+
Model = Model.with_context(project_id=project.id)
73+
return request.make_json_response(Model._api_request_read(data))
74+
75+
@route('/runbot/api/<model>/spec', auth='public', methods=['GET'], readonly=True)
76+
def spec(self, *, model: str):
77+
Model = self._get_model(model)
78+
required_keys = Model._api_request_required_keys()
79+
allowed_keys = Model._api_request_allowed_keys()
80+
return request.make_json_response({
81+
'requires_project': Model._api_request_requires_project(),
82+
'default_page_size': Model._api_request_default_limit(),
83+
'max_page_size': Model._api_request_max_limit(),
84+
'required_keys': list(Model._api_request_required_keys()),
85+
'allowed_keys': list(allowed_keys - required_keys),
86+
'specification': self._get_model(model)._api_public_specification(),
87+
})

runbot/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# -*- coding: utf-8 -*-
22

3+
from . import public_model_mixin
4+
35
from . import batch
46
from . import branch
57
from . import build

0 commit comments

Comments
 (0)