Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions endpoint/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from odoo import http
from odoo.http import Response, request
from odoo.tools.json import json_default


class EndpointControllerMixin:
Expand All @@ -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)
Comment on lines +45 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compose custom encoders with Odoo's fallback

When an endpoint's custom hook handles only an additional type—for example, Decimal—and the same payload contains an Odoo-supported value such as a date or Domain, json.dumps invokes only the custom hook. If that hook follows the normal protocol and raises TypeError for values it does not handle, the request returns a serialization error instead of falling back to Odoo's encoder as promised. Selecting one callable with or does not chain them; wrap the custom hook so unhandled values delegate to json_default, or require the hook itself to delegate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a5193bf. The behaviour is intentional: json.dumps takes a single default= callable, and chaining would mean catching TypeError around the hook, which would also swallow genuine errors raised inside it. The contract is that a custom hook replaces the default for the whole payload and delegates to json_default for the types it does not render itself — which is what endpoint_json2._json2_json_default does. The comment (and the PR description) claimed automatic fallback and has been corrected.

if headers is None:
headers = {}
headers["Content-Type"] = "application/json"
Expand Down
17 changes: 17 additions & 0 deletions endpoint/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
28 changes: 28 additions & 0 deletions endpoint/tests/test_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"}
)
13 changes: 13 additions & 0 deletions endpoint/tests/test_endpoint_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
31 changes: 13 additions & 18 deletions endpoint_json2/models/endpoint_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -417,24 +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")
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)
Expand Down Expand Up @@ -465,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}
54 changes: 35 additions & 19 deletions endpoint_json2/tests/test_endpoint_json2.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# 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
from odoo import Command, fields
from odoo.exceptions import ValidationError

from odoo.addons.http_routing.tests.common import MockRequest

from .common import CommonEndpointJson2


Expand Down Expand Up @@ -227,25 +231,37 @@ 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"),
# 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;
# 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_list(self):
result = {
"name": "Test",
"tag_dates": [datetime(2026, 1, 1), datetime(2026, 2, 1)],
}
serialized = self.endpoint._json2_serialize_values(result)
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(
serialized["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",
)
Loading