From b1428cb844e5173957b88f09e1a941dd97044148 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 29 Aug 2026 15:15:38 +0000 Subject: [PATCH] [FIX] endpoint: serialize payload values with Odoo's json_default _make_json_response called json.dumps() with no default= hook, so any payload value json cannot represent natively raised TypeError and the request returned a 500. Every other JSON response in Odoo goes through json.dumps(data, default=json_default) (odoo/http.py). Pass the same hook here. It covers date, datetime, bytes, Domain, lazy and ReadonlyDict, and json.dumps applies it at every depth, so nested values are handled without walking the payload. Also let an endpoint supply its own hook through the result dict, for exec modes that need to override how a type is rendered. json.dumps takes a single default= callable, so such a hook replaces Odoo's for the whole payload and is expected to delegate to json_default for the types it does not render itself. Assisted-by: Claude Opus 5 --- endpoint/README.rst | 3 +++ endpoint/controllers/main.py | 14 +++++++++-- endpoint/readme/CONTRIBUTORS.md | 2 ++ endpoint/static/description/index.html | 4 ++++ endpoint/tests/common.py | 17 +++++++++++++ endpoint/tests/test_endpoint.py | 28 ++++++++++++++++++++++ endpoint/tests/test_endpoint_controller.py | 13 ++++++++++ 7 files changed, 79 insertions(+), 2 deletions(-) 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")