diff --git a/endpoint/README.rst b/endpoint/README.rst index 7b66775e..70a5d14e 100644 --- a/endpoint/README.rst +++ b/endpoint/README.rst @@ -218,6 +218,9 @@ Contributors - Simone Orsi - Iván Todorovich - Alex Garcia +- `Quartile `__: + + - Yoshi Tashiro Maintainers ----------- diff --git a/endpoint/controllers/main.py b/endpoint/controllers/main.py index afae8a10..269094c5 100644 --- a/endpoint/controllers/main.py +++ b/endpoint/controllers/main.py @@ -9,6 +9,7 @@ from odoo import http from odoo.http import Response, request +from odoo.tools.json import json_default class EndpointControllerMixin: @@ -28,12 +29,21 @@ def _handle_result(self, result): payload = result.get("payload", "") status = result.get("status_code", 200) headers = result.get("headers", {}) - return self._make_json_response(payload, headers=headers, status=status) + return self._make_json_response( + payload, + headers=headers, + status=status, + json_default=result.get("json_default"), + ) # TODO: probably not needed anymore as controllers are automatically registered def _make_json_response(self, payload, headers=None, status=200, **kw): # TODO: guess out type? - data = json.dumps(payload) + # An endpoint can pass its own encoder hook, which then replaces Odoo's + # for the whole payload: it is expected to delegate to json_default for + # the types it does not render itself. + default = kw.get("json_default") or json_default + data = json.dumps(payload, default=default) if headers is None: headers = {} headers["Content-Type"] = "application/json" diff --git a/endpoint/readme/CONTRIBUTORS.md b/endpoint/readme/CONTRIBUTORS.md index 163a9802..9aed1d15 100644 --- a/endpoint/readme/CONTRIBUTORS.md +++ b/endpoint/readme/CONTRIBUTORS.md @@ -1,3 +1,5 @@ - Simone Orsi \<\> - Iván Todorovich \<\> - Alex Garcia \<\> +- [Quartile](https://www.quartile.co): + - Yoshi Tashiro diff --git a/endpoint/static/description/index.html b/endpoint/static/description/index.html index 408e3191..8fae8c0a 100644 --- a/endpoint/static/description/index.html +++ b/endpoint/static/description/index.html @@ -570,6 +570,10 @@

Contributors

  • Simone Orsi <simone.orsi@camptocamp.com>
  • Iván Todorovich <ivan.todorovich@camptocamp.com>
  • Alex Garcia <alex@studio73.es>
  • +
  • Quartile:
      +
    • Yoshi Tashiro
    • +
    +
  • diff --git a/endpoint/tests/common.py b/endpoint/tests/common.py index 7ab0eddd..07932ff2 100644 --- a/endpoint/tests/common.py +++ b/endpoint/tests/common.py @@ -137,6 +137,23 @@ def _setup_demo_records(env): ), } ) + endpoints += env["endpoint.endpoint"].create( + { + "name": "Demo Endpoint 10", + "route": "/demo/native_types", + "request_method": "GET", + "auth_type": "public", + "exec_as_user_id": demo_user.id, + "exec_mode": "code", + "code_snippet": ( + 'result = {"payload": {' + '"rule_domain": env["ir.rule"]._compute_domain("res.partner"), ' + '"a_date": datetime.date(2026, 1, 15), ' + '"a_datetime": datetime.datetime(2026, 1, 15, 10, 30, 0)' + "}}" + ), + } + ) return endpoints diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py index 49030bec..37498f67 100644 --- a/endpoint/tests/test_endpoint.py +++ b/endpoint/tests/test_endpoint.py @@ -4,14 +4,18 @@ import json import textwrap +from datetime import date from unittest import mock import psycopg2 import werkzeug from odoo import exceptions +from odoo.http import Response from odoo.tools.misc import mute_logger +from odoo.addons.endpoint.controllers.main import EndpointController + from .common import CommonEndpoint @@ -249,3 +253,27 @@ def test_registry_sync(self): def test_duplicate(self): endpoint = self.endpoint.copy() self.assertTrue(endpoint.route.endswith("/COPY_FIXME")) + + def _json_response(self, result): + """Render a result through the controller, as a request would.""" + with self._get_mocked_request() as req: + req.make_response = lambda data, **kw: Response(data, **kw) + return json.loads(EndpointController()._handle_result(result).data) + + def test_handle_result_json_default(self): + """A result can carry its own encoder for values json cannot render. + + The result dict is the only channel available: the controller sees + what the endpoint returned, not the endpoint itself. + """ + payload = {"val": date(2026, 1, 15)} + self.assertEqual( + self._json_response( + {"payload": payload, "json_default": lambda val: "hooked"} + ), + {"val": "hooked"}, + ) + # Without a hook, values fall back to Odoo's own encoder. + self.assertEqual( + self._json_response({"payload": payload}), {"val": "2026-01-15"} + ) diff --git a/endpoint/tests/test_endpoint_controller.py b/endpoint/tests/test_endpoint_controller.py index 78486e9e..2cfe7f3e 100644 --- a/endpoint/tests/test_endpoint_controller.py +++ b/endpoint/tests/test_endpoint_controller.py @@ -80,3 +80,16 @@ def test_call6(self): def test_call7(self): response = self.url_open("/demo/bad_method", data="ok") self.assertEqual(response.status_code, 405) + + def test_call_payload_native_types(self): + """Values the plain json encoder cannot handle must not break a payload. + + A Domain lands in a payload whenever a snippet passes an ORM helper's + return value through; dates come from any record field. + """ + response = self.url_open("/demo/native_types") + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode()) + self.assertIsInstance(data["rule_domain"], list) + self.assertEqual(data["a_date"], "2026-01-15") + self.assertEqual(data["a_datetime"], "2026-01-15 10:30:00")