From 3e690e544f5e6eb8d033892badc52174d816cad2 Mon Sep 17 00:00:00 2001 From: Aungkokolin1997 Date: Wed, 2 Sep 2026 03:57:12 +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, 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. Backport of OCA/web-api#160. Assisted-by: Claude Opus 5 --- endpoint/README.rst | 3 +++ endpoint/controllers/main.py | 14 +++++++++-- endpoint/demo/endpoint_demo.xml | 15 ++++++++++++ endpoint/readme/CONTRIBUTORS.md | 2 ++ endpoint/static/description/index.html | 4 ++++ endpoint/tests/test_endpoint.py | 28 ++++++++++++++++++++++ endpoint/tests/test_endpoint_controller.py | 12 ++++++++++ 7 files changed, 76 insertions(+), 2 deletions(-) diff --git a/endpoint/README.rst b/endpoint/README.rst index 844a1f99..eb4b64b5 100644 --- a/endpoint/README.rst +++ b/endpoint/README.rst @@ -82,6 +82,9 @@ Contributors ------------ - Simone Orsi +- `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/demo/endpoint_demo.xml b/endpoint/demo/endpoint_demo.xml index 290cfe30..aa8a57a2 100644 --- a/endpoint/demo/endpoint_demo.xml +++ b/endpoint/demo/endpoint_demo.xml @@ -81,4 +81,19 @@ result = {"response": Response(request.params.get("your_name", ""))} result = {"payload": "Method used:" + request.httprequest.method} + + + Demo Endpoint 8 + /demo/native_types + GET + public + + code + +result = {"payload": { + "a_date": datetime.date(2026, 1, 15), + "a_datetime": datetime.datetime(2026, 1, 15, 10, 30, 0), +}} + + diff --git a/endpoint/readme/CONTRIBUTORS.md b/endpoint/readme/CONTRIBUTORS.md index 2b66303a..669019e0 100644 --- a/endpoint/readme/CONTRIBUTORS.md +++ b/endpoint/readme/CONTRIBUTORS.md @@ -1 +1,3 @@ - Simone Orsi \<\> +- [Quartile](https://www.quartile.co): + - Yoshi Tashiro diff --git a/endpoint/static/description/index.html b/endpoint/static/description/index.html index 9dc3dc30..f7af53ba 100644 --- a/endpoint/static/description/index.html +++ b/endpoint/static/description/index.html @@ -428,6 +428,10 @@

Authors

Contributors

diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py index 4a1b3e82..9827b31a 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 @@ -247,3 +251,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 fb5e84fd..7ac38aa1 100644 --- a/endpoint/tests/test_endpoint_controller.py +++ b/endpoint/tests/test_endpoint_controller.py @@ -77,3 +77,15 @@ 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. + + Dates come from any record field, and reach a payload whenever a + snippet passes a field value through. + """ + response = self.url_open("/demo/native_types") + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode()) + self.assertEqual(data["a_date"], "2026-01-15") + self.assertEqual(data["a_datetime"], "2026-01-15 10:30:00")