From 7edaf54ac755e008177d13985166ae708d204e9b Mon Sep 17 00:00:00 2001 From: Morita Shinnosuke Date: Thu, 27 Aug 2026 10:30:37 +0000 Subject: [PATCH 1/5] [FIX] endpoint_json2: serialize Domain field values to JSON Odoo's fields.Domain (e.g. from fields.Domain.AND) is not JSON serializable. A field whose compute returns such an object (a common pattern for view-only domain widgets, as added by partner_contact_address_default in this PR) crashed the JSON2 response when included in the payload. Mirrors the same fix submitted upstream at OCA/web-api#135: https://github.com/OCA/web-api/pull/135 --- endpoint_json2/models/endpoint_endpoint.py | 2 ++ endpoint_json2/tests/test_endpoint_json2.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/endpoint_json2/models/endpoint_endpoint.py b/endpoint_json2/models/endpoint_endpoint.py index 8e4b997..f9c017a 100644 --- a/endpoint_json2/models/endpoint_endpoint.py +++ b/endpoint_json2/models/endpoint_endpoint.py @@ -427,6 +427,8 @@ def _json2_serialize_value(self, val): return val.isoformat() if isinstance(val, bytes): return val.decode("utf-8", errors="replace") + if isinstance(val, fields.Domain): + return list(val) return val def _json2_serialize_values(self, result): diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py index edd0a8a..03b10e7 100644 --- a/endpoint_json2/tests/test_endpoint_json2.py +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -3,7 +3,7 @@ from datetime import date, datetime -from odoo import Command +from odoo import Command, fields from odoo.exceptions import ValidationError from .common import CommonEndpointJson2 @@ -249,3 +249,16 @@ def test_serialize_values_nested_list(self): serialized["tag_dates"], ["2026-01-01T00:00:00+00:00", "2026-02-01T00:00:00+00:00"], ) + + def test_serialize_values_domain(self): + result = { + "name": "Test", + "domain": fields.Domain.AND( + [[("id", "child_of", [1])], [("type", "=", "delivery")]] + ), + } + serialized = self.endpoint._json2_serialize_values(result) + self.assertEqual( + serialized["domain"], + ["&", ("id", "child_of", [1]), ("type", "=", "delivery")], + ) From ef1d40eb777a9cf7aeaeec2567eecb1539651600 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 29 Aug 2026 07:51:30 +0000 Subject: [PATCH 2/5] [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; whatever it does not handle falls back to Odoo's default. Assisted-by: Claude Opus 5 --- endpoint/controllers/main.py | 13 +++++++++++-- endpoint/tests/common.py | 17 +++++++++++++++++ endpoint/tests/test_endpoint_controller.py | 13 +++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/endpoint/controllers/main.py b/endpoint/controllers/main.py index afae8a1..62f56a3 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,20 @@ 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 to override how a given type + # is rendered; anything it does not handle falls back to Odoo's default. + 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/tests/common.py b/endpoint/tests/common.py index 7ab0edd..07932ff 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_controller.py b/endpoint/tests/test_endpoint_controller.py index 78486e9..2cfe7f3 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") From 139ec4c9a7e2ab29a4a3d7d40d03c8aadea2c4d9 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 29 Aug 2026 07:51:40 +0000 Subject: [PATCH 3/5] [IMP] endpoint_json2: rely on json_default for response serialization _json2_serialize_values walked the whole payload to convert values json cannot represent, reimplementing odoo.tools.json.json_default without knowing it existed. It also converted containers without their contents, so 7edaf54 turned a Domain into its list form but left a date inside one of its conditions untouched. Drop the walker and pass an encoder hook to the controller instead. json.dumps applies it at every depth, so nested values are covered for free, and date, bytes and Domain need no handling of our own. Only datetime keeps a deviation: json_default renders naive UTC, which cannot express json2_tz, so datetimes stay ISO-8601 with an explicit offset. Note bytes now decode strictly -- Binary fields read back base64, so this is exact for real field values, where errors="replace" would have silently mangled anything else. Assisted-by: Claude Opus 5 --- endpoint_json2/models/endpoint_endpoint.py | 33 ++++------ endpoint_json2/tests/test_endpoint_json2.py | 70 +++++++++++++-------- 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/endpoint_json2/models/endpoint_endpoint.py b/endpoint_json2/models/endpoint_endpoint.py index f9c017a..e1baa84 100644 --- a/endpoint_json2/models/endpoint_endpoint.py +++ b/endpoint_json2/models/endpoint_endpoint.py @@ -2,13 +2,14 @@ # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). import json -from datetime import date, datetime +from datetime import datetime import werkzeug from odoo import Command, api, fields, models from odoo.exceptions import AccessError, ValidationError from odoo.service.model import get_public_method +from odoo.tools.json import json_default from odoo.tools.safe_eval import json as safe_json from odoo.tools.safe_eval import safe_eval, wrap_module @@ -417,26 +418,19 @@ def _rename(row): return _rename(result) return result - def _json2_serialize_value(self, val): + def _json2_json_default(self, val): + """Encoder hook for values json cannot represent. + + json.dumps applies this at every depth, so nested values are covered + without walking the payload ourselves. + """ if isinstance(val, datetime): - # Pin the timezone explicitly (UTC when unset) so that the offset does - # not silently follow the API user's timezone. + # Render as ISO-8601 with an explicit offset, pinning the timezone + # (UTC when unset) so it does not silently follow the API user's. + # json_default would give naive UTC, which cannot carry json2_tz. record = self.with_context(tz=self.json2_tz or "UTC") return fields.Datetime.context_timestamp(record, val).isoformat() - if isinstance(val, date): - return val.isoformat() - if isinstance(val, bytes): - return val.decode("utf-8", errors="replace") - if isinstance(val, fields.Domain): - return list(val) - return val - - def _json2_serialize_values(self, result): - if isinstance(result, list): - return [self._json2_serialize_values(item) for item in result] - if isinstance(result, dict): - return {k: self._json2_serialize_values(v) for k, v in result.items()} - return self._json2_serialize_value(result) + return json_default(val) def _handle_exec__json2(self, request): self._json2_check_group_access(request) @@ -467,5 +461,4 @@ def _handle_exec__json2(self, request): result = self._json2_filter_result(result, response_fields) if aliases: result = self._json2_apply_aliases(result, aliases) - result = self._json2_serialize_values(result) - return {"payload": result} + return {"payload": result, "json_default": self._json2_json_default} diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py index 03b10e7..963cf61 100644 --- a/endpoint_json2/tests/test_endpoint_json2.py +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -1,6 +1,8 @@ # Copyright 2026 Quartile (https://www.quartile.co) # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +import base64 +import json from datetime import date, datetime from odoo import Command, fields @@ -227,38 +229,54 @@ def test_filter_excludes_base_when_only_dotted(self): ) self.assertEqual(aliased, {"name": "Test", "country": "Japan"}) + def _encode(self, payload): + """Encode as the controller does, through the endpoint's hook.""" + return json.loads( + json.dumps(payload, default=self.endpoint._json2_json_default) + ) + def test_serialize_values(self): - result = { - "name": "Test", - "write_date": datetime(2026, 1, 15, 10, 30, 0), - "date": date(2026, 1, 15), - "avatar": b"\x89PNG", - } - serialized = self.endpoint._json2_serialize_values(result) - self.assertEqual(serialized["write_date"], "2026-01-15T10:30:00+00:00") - self.assertEqual(serialized["date"], "2026-01-15") - self.assertIsInstance(serialized["avatar"], str) + encoded = self._encode( + { + "name": "Test", + "write_date": datetime(2026, 1, 15, 10, 30, 0), + "date": date(2026, 1, 15), + # Binary fields read back base64-encoded, hence ascii. + "avatar": base64.b64encode(b"\x89PNG"), + } + ) + # Datetimes carry an explicit offset so json2_tz is unambiguous; dates + # and bytes are left to Odoo's own encoder. + self.assertEqual(encoded["write_date"], "2026-01-15T10:30:00+00:00") + self.assertEqual(encoded["date"], "2026-01-15") + self.assertEqual(encoded["avatar"], "iVBORw==") - def test_serialize_values_nested_list(self): - result = { - "name": "Test", - "tag_dates": [datetime(2026, 1, 1), datetime(2026, 2, 1)], - } - serialized = self.endpoint._json2_serialize_values(result) + def test_serialize_values_nested(self): + encoded = self._encode( + {"tag_dates": [datetime(2026, 1, 1), datetime(2026, 2, 1)]} + ) self.assertEqual( - serialized["tag_dates"], + encoded["tag_dates"], ["2026-01-01T00:00:00+00:00", "2026-02-01T00:00:00+00:00"], ) def test_serialize_values_domain(self): - result = { - "name": "Test", - "domain": fields.Domain.AND( - [[("id", "child_of", [1])], [("type", "=", "delivery")]] - ), - } - serialized = self.endpoint._json2_serialize_values(result) + """A Domain reaches the payload from any field computed with one.""" + encoded = self._encode( + { + "domain": fields.Domain.AND( + [[("id", "child_of", [1])], [("type", "=", "delivery")]] + ) + } + ) self.assertEqual( - serialized["domain"], - ["&", ("id", "child_of", [1]), ("type", "=", "delivery")], + encoded["domain"], + ["&", ["id", "child_of", [1]], ["type", "=", "delivery"]], + ) + + def test_serialize_values_domain_nested_date(self): + """Values inside a domain need converting too, at any depth.""" + encoded = self._encode( + {"domain": fields.Domain([("date_order", ">=", date(2026, 1, 15))])} ) + self.assertEqual(encoded["domain"], [["date_order", ">=", "2026-01-15"]]) From 1d937d04ba9b121a41549910108ebbef0fb463b1 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 29 Aug 2026 12:50:25 +0000 Subject: [PATCH 4/5] [IMP] endpoint, endpoint_json2: cover the response encoder hook Nothing asserted that a hook supplied through the result dict reaches json.dumps, nor that _handle_exec__json2 hands its own hook over: the json2 tests called the hook directly, so a response could fall back to the default encoder with the suite still green. Add a controller-level test for both branches of the hook lookup, and one assertion on what _handle_exec__json2 returns. Drop the serialization tests that now only exercise odoo.tools.json.json_default, keeping the Domain case as a single assertion whose date inside a condition still covers conversion at depth. Assisted-by: Claude Opus 5 --- endpoint/tests/test_endpoint.py | 28 ++++++++++++++ endpoint_json2/tests/test_endpoint_json2.py | 43 +++++++-------------- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py index 6c41a80..c1e48ef 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 @@ -246,3 +250,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_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py index 963cf61..64a19fb 100644 --- a/endpoint_json2/tests/test_endpoint_json2.py +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -8,6 +8,8 @@ from odoo import Command, fields from odoo.exceptions import ValidationError +from odoo.addons.http_routing.tests.common import MockRequest + from .common import CommonEndpointJson2 @@ -243,40 +245,23 @@ def test_serialize_values(self): "date": date(2026, 1, 15), # Binary fields read back base64-encoded, hence ascii. "avatar": base64.b64encode(b"\x89PNG"), + # A Domain reaches the payload from any field computed with one. + "domain": fields.Domain([("date_order", ">=", date(2026, 1, 15))]), } ) - # Datetimes carry an explicit offset so json2_tz is unambiguous; dates - # and bytes are left to Odoo's own encoder. + # Datetimes carry an explicit offset so json2_tz is unambiguous; + # everything else is left to Odoo's own encoder, at any depth. self.assertEqual(encoded["write_date"], "2026-01-15T10:30:00+00:00") self.assertEqual(encoded["date"], "2026-01-15") self.assertEqual(encoded["avatar"], "iVBORw==") + self.assertEqual(encoded["domain"], [["date_order", ">=", "2026-01-15"]]) - def test_serialize_values_nested(self): - encoded = self._encode( - {"tag_dates": [datetime(2026, 1, 1), datetime(2026, 2, 1)]} - ) + def test_handle_exec_carries_encoder(self): + """The hook only applies if the result hands it to the controller.""" + with MockRequest(self.env) as req: + req.get_json_data = lambda: {} + result = self.endpoint._handle_exec__json2(req) self.assertEqual( - encoded["tag_dates"], - ["2026-01-01T00:00:00+00:00", "2026-02-01T00:00:00+00:00"], + result["json_default"](datetime(2026, 1, 15, 10, 30, 0)), + "2026-01-15T10:30:00+00:00", ) - - def test_serialize_values_domain(self): - """A Domain reaches the payload from any field computed with one.""" - encoded = self._encode( - { - "domain": fields.Domain.AND( - [[("id", "child_of", [1])], [("type", "=", "delivery")]] - ) - } - ) - self.assertEqual( - encoded["domain"], - ["&", ["id", "child_of", [1]], ["type", "=", "delivery"]], - ) - - def test_serialize_values_domain_nested_date(self): - """Values inside a domain need converting too, at any depth.""" - encoded = self._encode( - {"domain": fields.Domain([("date_order", ">=", date(2026, 1, 15))])} - ) - self.assertEqual(encoded["domain"], [["date_order", ">=", "2026-01-15"]]) From a5193bfb35300e4b5461808bd0ba614cf62ba780 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 29 Aug 2026 14:20:54 +0000 Subject: [PATCH 5/5] [IMP] endpoint: state the encoder hook contract precisely The comment read as if the controller chained a custom hook with Odoo's encoder, but json.dumps takes a single default= callable: a hook that raises TypeError for a type it does not know would return a serialization error rather than fall back. Say instead that the hook replaces the default and is expected to delegate to json_default itself, which is what endpoint_json2 does. Assisted-by: Claude Opus 5 --- endpoint/controllers/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/endpoint/controllers/main.py b/endpoint/controllers/main.py index 62f56a3..269094c 100644 --- a/endpoint/controllers/main.py +++ b/endpoint/controllers/main.py @@ -39,8 +39,9 @@ def _handle_result(self, result): # 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? - # An endpoint can pass its own encoder hook to override how a given type - # is rendered; anything it does not handle falls back to Odoo's default. + # 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: