From 52ef6b580cd6c6c332c7322e845419adf21fa309 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 23 May 2026 10:55:40 +0000 Subject: [PATCH 01/16] [ADD] endpoint_json2: declarative JSON2-RPC endpoints on the endpoint stack --- endpoint_json2/__init__.py | 1 + endpoint_json2/__manifest__.py | 19 + endpoint_json2/controllers/__init__.py | 1 + endpoint_json2/controllers/main.py | 71 ++++ endpoint_json2/demo/endpoint_json2_demo.xml | 111 ++++++ endpoint_json2/models/__init__.py | 1 + endpoint_json2/models/endpoint_json2_param.py | 54 +++ endpoint_json2/models/endpoint_mixin.py | 267 ++++++++++++++ endpoint_json2/pyproject.toml | 3 + endpoint_json2/readme/CONTRIBUTORS.md | 1 + endpoint_json2/readme/DESCRIPTION.md | 7 + endpoint_json2/security/ir.model.access.csv | 2 + endpoint_json2/static/description/icon.png | Bin 0 -> 9455 bytes endpoint_json2/tests/__init__.py | 1 + endpoint_json2/tests/common.py | 27 ++ endpoint_json2/tests/test_endpoint_json2.py | 151 ++++++++ .../tests/test_endpoint_json2_controller.py | 332 ++++++++++++++++++ endpoint_json2/views/endpoint_json2_view.xml | 57 +++ 18 files changed, 1106 insertions(+) create mode 100644 endpoint_json2/__init__.py create mode 100644 endpoint_json2/__manifest__.py create mode 100644 endpoint_json2/controllers/__init__.py create mode 100644 endpoint_json2/controllers/main.py create mode 100644 endpoint_json2/demo/endpoint_json2_demo.xml create mode 100644 endpoint_json2/models/__init__.py create mode 100644 endpoint_json2/models/endpoint_json2_param.py create mode 100644 endpoint_json2/models/endpoint_mixin.py create mode 100644 endpoint_json2/pyproject.toml create mode 100644 endpoint_json2/readme/CONTRIBUTORS.md create mode 100644 endpoint_json2/readme/DESCRIPTION.md create mode 100644 endpoint_json2/security/ir.model.access.csv create mode 100644 endpoint_json2/static/description/icon.png create mode 100644 endpoint_json2/tests/__init__.py create mode 100644 endpoint_json2/tests/common.py create mode 100644 endpoint_json2/tests/test_endpoint_json2.py create mode 100644 endpoint_json2/tests/test_endpoint_json2_controller.py create mode 100644 endpoint_json2/views/endpoint_json2_view.xml diff --git a/endpoint_json2/__init__.py b/endpoint_json2/__init__.py new file mode 100644 index 0000000..72d3ea6 --- /dev/null +++ b/endpoint_json2/__init__.py @@ -0,0 +1 @@ +from . import controllers, models diff --git a/endpoint_json2/__manifest__.py b/endpoint_json2/__manifest__.py new file mode 100644 index 0000000..e992720 --- /dev/null +++ b/endpoint_json2/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +{ + "name": "Endpoint JSON2", + "summary": "Declarative JSON2-RPC endpoints on the endpoint stack.", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "development_status": "Beta", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web-api", + "category": "Technical", + "depends": ["endpoint"], + "data": [ + "security/ir.model.access.csv", + "views/endpoint_json2_view.xml", + ], + "demo": ["demo/endpoint_json2_demo.xml"], + "installable": True, +} diff --git a/endpoint_json2/controllers/__init__.py b/endpoint_json2/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/endpoint_json2/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/endpoint_json2/controllers/main.py b/endpoint_json2/controllers/main.py new file mode 100644 index 0000000..c3c9502 --- /dev/null +++ b/endpoint_json2/controllers/main.py @@ -0,0 +1,71 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class EndpointJson2DocController(http.Controller): + @http.route( + "/json2/endpoint/doc", + methods=["GET"], + auth="bearer", + type="http", + readonly=True, + save_session=False, + ) + def doc_index(self): + endpoints = self._get_accessible_endpoints() + result = {} + for ep in endpoints: + result.setdefault(ep.route_group, []).append(self._endpoint_to_doc(ep)) + return request.make_json_response(result) + + @http.route( + "/json2/endpoint/doc/", + methods=["GET"], + auth="bearer", + type="http", + readonly=True, + save_session=False, + ) + def doc_domain(self, route_group): + endpoints = self._get_accessible_endpoints( + [("route_group", "=", route_group)] + ) + if not endpoints: + raise NotFound(f"No endpoints found for domain {route_group!r}") + return request.make_json_response( + [self._endpoint_to_doc(ep) for ep in endpoints] + ) + + def _get_accessible_endpoints(self, extra_domain=None): + domain = [("exec_mode", "=", "json2")] + (extra_domain or []) + all_endpoints = request.env["endpoint.endpoint"].sudo().search(domain) + user = request.env.user + return all_endpoints.filtered( + lambda ep: not ep.json2_group_ids + or (ep.json2_group_ids & user.groups_id) + ) + + @staticmethod + def _endpoint_to_doc(endpoint): + return { + "name": endpoint.name, + "description": endpoint.json2_description or "", + "method": endpoint.json2_method, + "model": endpoint.json2_model_name, + "url": endpoint.route, + "parameters": [ + { + "name": p.name, + "type": p.param_type, + "required": p.required, + "description": p.description or "", + "default": p.default_value, + } + for p in endpoint.json2_param_ids + ], + } diff --git a/endpoint_json2/demo/endpoint_json2_demo.xml b/endpoint_json2/demo/endpoint_json2_demo.xml new file mode 100644 index 0000000..59c5cab --- /dev/null +++ b/endpoint_json2/demo/endpoint_json2_demo.xml @@ -0,0 +1,111 @@ + + + + + + get_partners + /json2/endpoint/contacts/get_partners + contacts + json2 + POST + application/json + bearer + Return partner records matching the given domain. + + search_read + name,email,phone,city,country_id + [["active", "=", true]] + + + + domain + list + + [] + 10 + + + + limit + integer + + 80 + 20 + + + + fields + list + + 30 + + + + + update_partner_name + /json2/endpoint/contacts/update_partner_name + contacts + json2 + POST + application/json + bearer + Update a partner's name by ref (code snippet example). + + ref,name + +partner = Model.search([("ref", "=", params["ref"])], limit=1) +if not partner: + raise exceptions.NotFound("Partner not found: " + params["ref"]) +partner.write({"name": params["new_name"]}) +result = {"ref": partner.ref, "name": partner.name} + + + + + ref + string + + 10 + + + + new_name + string + + 20 + + + + + get_countries + /json2/endpoint/reference/get_countries + reference + json2 + POST + application/json + bearer + Return country records. + + search_read + name,code,phone_code + [] + + + + domain + list + + [] + 10 + + diff --git a/endpoint_json2/models/__init__.py b/endpoint_json2/models/__init__.py new file mode 100644 index 0000000..d6b9400 --- /dev/null +++ b/endpoint_json2/models/__init__.py @@ -0,0 +1 @@ +from . import endpoint_json2_param, endpoint_mixin diff --git a/endpoint_json2/models/endpoint_json2_param.py b/endpoint_json2/models/endpoint_json2_param.py new file mode 100644 index 0000000..905c2b3 --- /dev/null +++ b/endpoint_json2/models/endpoint_json2_param.py @@ -0,0 +1,54 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class EndpointJson2Param(models.Model): + _name = "endpoint.json2.param" + _description = "JSON2 Endpoint Parameter" + _order = "sequence, id" + + endpoint_id = fields.Many2one( + "endpoint.endpoint", + required=True, + ondelete="cascade", + ) + name = fields.Char(required=True, help="Parameter name as sent in the JSON body.") + description = fields.Char(help="Displayed in the API documentation.") + param_type = fields.Selection( + [ + ("string", "String"), + ("integer", "Integer"), + ("float", "Float"), + ("boolean", "Boolean"), + ("list", "List"), + ("dict", "Dict"), + ], + string="Type", + required=True, + default="string", + ) + required = fields.Boolean() + default_value = fields.Char( + help="Default value (JSON-encoded) when the parameter is not provided.", + ) + sequence = fields.Integer(default=10) + + @api.constrains("default_value") + def _check_default_value(self): + for rec in self: + if not rec.default_value: + continue + try: + json.loads(rec.default_value) + except json.JSONDecodeError: + raise ValidationError( + self.env._( + "Default value must be valid JSON: %(value)s", + value=rec.default_value, + ) + ) from None diff --git a/endpoint_json2/models/endpoint_mixin.py b/endpoint_json2/models/endpoint_mixin.py new file mode 100644 index 0000000..626e5cd --- /dev/null +++ b/endpoint_json2/models/endpoint_mixin.py @@ -0,0 +1,267 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json + +import werkzeug + +from odoo import api, fields, models +from odoo.exceptions import AccessError, ValidationError +from odoo.service.model import get_public_method +from odoo.tools.safe_eval import json as safe_json +from odoo.tools.safe_eval import safe_eval, wrap_module + +PARAM_TYPE_MAP = { + "string": str, + "integer": int, + "float": float, + "boolean": bool, + "list": list, + "dict": dict, +} + + +class EndpointMixin(models.AbstractModel): + _inherit = "endpoint.mixin" + + json2_model_id = fields.Many2one( + "ir.model", + string="Model", + ondelete="cascade", + domain=[("transient", "=", False)], + ) + json2_model_name = fields.Char( + related="json2_model_id.model", + store=True, + ) + json2_method = fields.Char( + string="Method", + help="Public method name on the target model.", + ) + json2_description = fields.Text( + string="Description", + help="Displayed in the API documentation endpoint.", + ) + json2_allowed_fields = fields.Char( + string="Allowed Fields", + help="Comma-separated list of field names the API may return. " + "Leave empty to allow all fields.", + ) + json2_default_domain = fields.Char( + string="Default Domain", + default="[]", + help="Default domain filter applied before calling the method (JSON format).", + ) + json2_group_ids = fields.Many2many( + "res.groups", + string="Allowed Groups", + help="Groups allowed to call this endpoint. " + "Leave empty to allow any authenticated API user.", + ) + json2_param_ids = fields.One2many( + "endpoint.json2.param", + "endpoint_id", + string="Parameters", + ) + json2_code_snippet = fields.Text( + string="Code Snippet", + help="Optional Python code executed instead of the model method. " + "Available variables: Model, params, env, json, exceptions. " + "Use record.write({...}) for updates. " + "Set the result in the 'result' variable.", + ) + + def _selection_exec_mode(self): + return super()._selection_exec_mode() + [("json2", "JSON2-RPC")] + + @api.depends("route", "exec_mode", "route_group", "name") + def _compute_route(self): + for rec in self: + if rec.exec_mode == "json2" and rec.route_group and rec.name: + rec.route = f"/json2/endpoint/{rec.route_group}/{rec.name}" + else: + rec.route = rec._clean_route() + + @api.onchange("exec_mode") + def _onchange_exec_mode_json2_defaults(self): + if self.exec_mode == "json2": + self.request_method = "POST" + self.request_content_type = "application/json" + self.auth_type = "bearer" + + # --- Validation --- + + def _validate_exec__json2(self): + if not self.json2_model_id: + raise ValidationError( + self.env._("Exec mode is set to 'JSON2-RPC': you must select a model.") + ) + if not self.json2_method and not self.json2_code_snippet: + raise ValidationError( + self.env._( + "Exec mode is set to 'JSON2-RPC': " + "you must specify a method or provide a code snippet." + ) + ) + + @api.constrains("request_method", "request_content_type", "exec_mode") + def _check_json2_request_settings(self): + for rec in self: + if rec.exec_mode != "json2": + continue + if rec.request_method != "POST": + raise ValidationError( + self.env._( + "JSON2-RPC endpoints must use POST " + "(parameters are sent as a JSON body)." + ) + ) + if rec.request_content_type != "application/json": + raise ValidationError( + self.env._( + "JSON2-RPC endpoints must use 'application/json' " + "content type." + ) + ) + + @api.constrains("json2_method") + def _check_json2_method(self): + for rec in self: + if rec.json2_method and rec.json2_method.startswith("_"): + raise ValidationError( + self.env._( + "Private methods (starting with '_') cannot be exposed." + ) + ) + + @api.constrains("json2_default_domain") + def _check_json2_default_domain(self): + for rec in self: + if not rec.json2_default_domain: + continue + try: + domain = json.loads(rec.json2_default_domain) + if not isinstance(domain, list): + raise ValueError + except (json.JSONDecodeError, ValueError): + raise ValidationError( + self.env._("Default domain must be a valid JSON list.") + ) from None + + @api.constrains("json2_allowed_fields", "json2_model_id") + def _check_json2_allowed_fields(self): + for rec in self: + if not rec.json2_allowed_fields or not rec.json2_model_name: + continue + if rec.json2_model_name not in self.env: + continue + Model = self.env[rec.json2_model_name] + field_names = [f.strip() for f in rec.json2_allowed_fields.split(",")] + invalid = [f for f in field_names if f not in Model._fields] + if invalid: + raise ValidationError( + self.env._( + "Invalid field(s) for %(model)s: %(fields)s", + model=rec.json2_model_name, + fields=", ".join(invalid), + ) + ) + + # --- Execution --- + + def _handle_exec__json2(self, request): + self._json2_check_group_access(request) + kwargs = request.get_json_data() or {} + params = self._json2_validate_params(kwargs) + Model = request.env[self.json2_model_name].sudo() + default_domain = json.loads(self.json2_default_domain or "[]") + if default_domain: + params["domain"] = default_domain + (params.get("domain") or []) + if self.json2_code_snippet: + result = self._json2_exec_code_snippet(Model, params) + else: + try: + method = get_public_method(Model, self.json2_method) + except (AttributeError, AccessError) as exc: + raise werkzeug.exceptions.NotFound(str(exc)) from exc + result = method(Model, **params) + allowed = self._json2_get_allowed_field_list() + result = self._json2_filter_result(result, allowed) + return {"payload": result} + + def _json2_exec_code_snippet(self, Model, params): + eval_ctx = { + "Model": Model, + "params": params, + "env": Model.env, + "json": safe_json, + "exceptions": wrap_module(werkzeug.exceptions, [ + "BadRequest", "Forbidden", "NotFound", + "UnprocessableEntity", "InternalServerError", + ]), + } + safe_eval(self.json2_code_snippet, eval_ctx, mode="exec") + if "result" not in eval_ctx: + raise werkzeug.exceptions.InternalServerError( + "Code snippet must set a 'result' variable." + ) + return eval_ctx["result"] + + def _json2_check_group_access(self, request): + if not self.json2_group_ids: + return + if not (self.json2_group_ids & request.env.user.groups_id): + raise werkzeug.exceptions.Forbidden( + "User does not belong to any allowed group" + ) + + def _json2_validate_params(self, kwargs): + params = {} + for param_def in self.json2_param_ids: + value = kwargs.pop(param_def.name, None) + if value is None and param_def.default_value: + value = json.loads(param_def.default_value) + if value is None and param_def.required: + raise werkzeug.exceptions.UnprocessableEntity( + f"Missing required parameter: {param_def.name}" + ) + if value is not None: + expected_type = PARAM_TYPE_MAP.get(param_def.param_type) + if expected_type and not self._json2_check_param_type( + value, expected_type + ): + raise werkzeug.exceptions.UnprocessableEntity( + f"Parameter {param_def.name!r} must be of type " + f"{param_def.param_type}" + ) + params[param_def.name] = value + return params + + @staticmethod + def _json2_check_param_type(value, expected_type): + if isinstance(value, bool) and expected_type is not bool: + return False + if expected_type is float: + return isinstance(value, (int, float)) + return isinstance(value, expected_type) + + def _json2_get_allowed_field_list(self): + self.ensure_one() + if not self.json2_allowed_fields: + return [] + return [f.strip() for f in self.json2_allowed_fields.split(",")] + + @staticmethod + def _json2_filter_result(result, allowed_fields): + if not allowed_fields: + return result + if isinstance(result, list): + return [ + {k: v for k, v in row.items() if k in allowed_fields} + if isinstance(row, dict) + else row + for row in result + ] + if isinstance(result, dict): + return {k: v for k, v in result.items() if k in allowed_fields} + return result diff --git a/endpoint_json2/pyproject.toml b/endpoint_json2/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/endpoint_json2/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/endpoint_json2/readme/CONTRIBUTORS.md b/endpoint_json2/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..2e5eff5 --- /dev/null +++ b/endpoint_json2/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Yoshi Tashiro (Quartile) \ diff --git a/endpoint_json2/readme/DESCRIPTION.md b/endpoint_json2/readme/DESCRIPTION.md new file mode 100644 index 0000000..31b3605 --- /dev/null +++ b/endpoint_json2/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +Adds `exec_mode="json2"` to the endpoint framework, enabling declarative +JSON2-RPC endpoint configuration. Instead of writing code snippets, select a +model, method, and parameters — the module handles dispatch, parameter +validation, access control, and result filtering. + +Also provides auto-generated API documentation endpoints at +`/json2/endpoint/doc`. diff --git a/endpoint_json2/security/ir.model.access.csv b/endpoint_json2/security/ir.model.access.csv new file mode 100644 index 0000000..6303efb --- /dev/null +++ b/endpoint_json2/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_json2_param,endpoint.json2.param,model_endpoint_json2_param,base.group_system,1,1,1,1 diff --git a/endpoint_json2/static/description/icon.png b/endpoint_json2/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/endpoint_json2/tests/__init__.py b/endpoint_json2/tests/__init__.py new file mode 100644 index 0000000..bbd2ffc --- /dev/null +++ b/endpoint_json2/tests/__init__.py @@ -0,0 +1 @@ +from . import test_endpoint_json2, test_endpoint_json2_controller diff --git a/endpoint_json2/tests/common.py b/endpoint_json2/tests/common.py new file mode 100644 index 0000000..4ba4a5a --- /dev/null +++ b/endpoint_json2/tests/common.py @@ -0,0 +1,27 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests.common import TransactionCase, tagged + + +@tagged("-at_install", "post_install") +class CommonEndpointJson2(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.model_partner = cls.env["ir.model"]._get("res.partner") + cls.endpoint = cls.env["endpoint.endpoint"].create( + { + "name": "get_partners", + "route_group": "contacts", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_description": "Return partner records", + "json2_model_id": cls.model_partner.id, + "json2_method": "search_read", + "json2_allowed_fields": "name,email", + "json2_default_domain": "[]", + } + ) diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py new file mode 100644 index 0000000..dbb7014 --- /dev/null +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -0,0 +1,151 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.exceptions import ValidationError + +from .common import CommonEndpointJson2 + + +class TestEndpointJson2(CommonEndpointJson2): + def test_create_endpoint(self): + self.assertEqual(self.endpoint.json2_model_name, "res.partner") + self.assertEqual( + self.endpoint._json2_get_allowed_field_list(), ["name", "email"] + ) + + def test_route_auto_computed(self): + self.assertEqual(self.endpoint.route, "/json2/endpoint/contacts/get_partners") + + def test_private_method_rejected(self): + with self.assertRaises(ValidationError): + self.env["endpoint.endpoint"].create( + { + "name": "bad_endpoint", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "_compute_display_name", + } + ) + + def test_invalid_domain(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_default_domain = "not valid json" + + def test_domain_not_list(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_default_domain = '{"key": "value"}' + + def test_invalid_allowed_fields(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_allowed_fields = "name,nonexistent_field" + + def test_empty_allowed_fields(self): + self.endpoint.json2_allowed_fields = False + self.assertEqual(self.endpoint._json2_get_allowed_field_list(), []) + + def test_param_creation(self): + param = self.env["endpoint.json2.param"].create( + { + "endpoint_id": self.endpoint.id, + "name": "domain", + "param_type": "list", + "required": False, + "default_value": "[]", + } + ) + self.assertEqual(param.endpoint_id, self.endpoint) + self.assertIn(param, self.endpoint.json2_param_ids) + + def test_param_invalid_default_value(self): + with self.assertRaises(ValidationError): + self.env["endpoint.json2.param"].create( + { + "endpoint_id": self.endpoint.id, + "name": "bad_param", + "param_type": "string", + "default_value": "not valid json", + } + ) + + def test_filter_result_dict(self): + result = {"name": "Test", "email": "a@b.c", "phone": "123"} + filtered = self.endpoint._json2_filter_result(result, ["name", "email"]) + self.assertEqual(filtered, {"name": "Test", "email": "a@b.c"}) + + def test_filter_result_list(self): + result = [ + {"name": "A", "phone": "1"}, + {"name": "B", "phone": "2"}, + ] + filtered = self.endpoint._json2_filter_result(result, ["name"]) + self.assertEqual(filtered, [{"name": "A"}, {"name": "B"}]) + + def test_filter_result_passthrough(self): + self.assertEqual(self.endpoint._json2_filter_result(42, ["name"]), 42) + + def test_filter_result_no_filter(self): + result = {"name": "Test", "phone": "123"} + self.assertEqual(self.endpoint._json2_filter_result(result, []), result) + + def test_request_method_must_be_post(self): + with self.assertRaises(ValidationError): + self.env["endpoint.endpoint"].create( + { + "name": "get_test", + "route_group": "test", + "exec_mode": "json2", + "request_method": "GET", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + } + ) + + def test_content_type_must_be_json(self): + with self.assertRaises(ValidationError): + self.env["endpoint.endpoint"].create( + { + "name": "form_test", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "text/html", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + } + ) + + def test_validate_method_or_snippet_required(self): + with self.assertRaises(ValidationError): + self.env["endpoint.endpoint"].create( + { + "name": "no_method_no_snippet", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + } + ) + + def test_validate_snippet_without_method_ok(self): + ep = self.env["endpoint.endpoint"].create( + { + "name": "snippet_only", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_code_snippet": "result = []", + } + ) + self.assertTrue(ep.json2_code_snippet) diff --git a/endpoint_json2/tests/test_endpoint_json2_controller.py b/endpoint_json2/tests/test_endpoint_json2_controller.py new file mode 100644 index 0000000..27ad51a --- /dev/null +++ b/endpoint_json2/tests/test_endpoint_json2_controller.py @@ -0,0 +1,332 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import os +from datetime import datetime, timedelta +from unittest import skipIf + +from odoo.tests import new_test_user, tagged +from odoo.tests.common import HttpCase + +CT_JSON = {"Content-Type": "application/json"} + + +@skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") +@tagged("-at_install", "post_install") +class TestEndpointJson2Controller(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.api_user = new_test_user( + cls.env, + "json2_api_user", + groups="base.group_user", + ) + key = ( + cls.api_user.with_user(cls.api_user) + .env["res.users.apikeys"] + ._generate( + scope="rpc", + name="test", + expiration_date=datetime.now() + timedelta(days=1), + ) + ) + cls.bearer = {"Authorization": f"Bearer {key}"} + cls.model_partner = cls.env["ir.model"]._get("res.partner") + cls.endpoint = cls.env["endpoint.endpoint"].create( + { + "name": "get_partners", + "route_group": "contacts", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_description": "Return partner records", + "json2_model_id": cls.model_partner.id, + "json2_method": "search_read", + "json2_allowed_fields": "name,email", + "json2_default_domain": '[["is_company", "=", true]]', + } + ) + cls.env["endpoint.json2.param"].create( + [ + { + "endpoint_id": cls.endpoint.id, + "name": "domain", + "param_type": "list", + "required": False, + "default_value": "[]", + "sequence": 10, + }, + { + "endpoint_id": cls.endpoint.id, + "name": "limit", + "param_type": "integer", + "required": False, + "default_value": "10", + "sequence": 20, + }, + { + "endpoint_id": cls.endpoint.id, + "name": "fields", + "param_type": "list", + "required": False, + "sequence": 30, + }, + ] + ) + cls.env["endpoint.endpoint"].search([])._handle_registry_sync() + + def tearDown(self): + self.env.registry.clear_cache("routing") + super().tearDown() + + def _call(self, route_group, endpoint_name, payload=None): + url = f"/json2/endpoint/{route_group}/{endpoint_name}" + return self.url_open( + url, + data=json.dumps(payload or {}), + headers=CT_JSON | self.bearer, + ) + + def _call_doc(self, path=""): + url = f"/json2/endpoint/doc{path}" + return self.url_open( + url, + headers=self.bearer, + allow_redirects=False, + ) + + def test_dispatch_happy_path(self): + res = self._call("contacts", "get_partners") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIsInstance(data, list) + for row in data: + self.assertIn("name", row) + self.assertNotIn("phone", row) + + def test_dispatch_with_limit(self): + res = self._call("contacts", "get_partners", {"limit": 2}) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertLessEqual(len(data), 2) + + def test_dispatch_not_found(self): + res = self._call("contacts", "nonexistent") + self.assertEqual(res.status_code, 404) + + def test_dispatch_unknown_domain(self): + res = self._call("unknown_domain", "get_partners") + self.assertEqual(res.status_code, 404) + + def test_dispatch_inactive_endpoint(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "inactive_test", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + "active": False, + } + ) + endpoint._handle_registry_sync() + res = self._call("test", endpoint.name) + self.assertEqual(res.status_code, 404) + + def test_dispatch_required_param_missing(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "get_required", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "domain", + "param_type": "list", + "required": True, + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "get_required") + self.assertEqual(res.status_code, 422) + + def test_dispatch_wrong_param_type(self): + res = self._call("contacts", "get_partners", {"limit": "not_an_int"}) + self.assertEqual(res.status_code, 422) + + def test_dispatch_bool_rejected_for_int(self): + res = self._call("contacts", "get_partners", {"limit": True}) + self.assertEqual(res.status_code, 422) + + def test_dispatch_int_accepted_for_float(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "float_test", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "limit", + "param_type": "float", + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "float_test", {"limit": 5}) + self.assertEqual(res.status_code, 200) + + def test_dispatch_default_domain_applied(self): + self.env["res.partner"].create({"name": "Test Individual", "is_company": False}) + res = self._call("contacts", "get_partners") + self.assertEqual(res.status_code, 200) + data = res.json() + names = [row["name"] for row in data] + self.assertNotIn("Test Individual", names) + + def test_dispatch_group_access_denied(self): + group = self.env["res.groups"].create({"name": "Secret API Group"}) + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "restricted", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + "json2_group_ids": [(4, group.id)], + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "restricted") + self.assertEqual(res.status_code, 403) + + def test_doc_index(self): + res = self._call_doc() + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIsInstance(data, dict) + self.assertIn("contacts", data) + names = [ep["name"] for ep in data["contacts"]] + self.assertIn("get_partners", names) + + def test_doc_domain_filter(self): + res = self._call_doc("/contacts") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIsInstance(data, list) + self.assertTrue(data) + + def test_doc_unknown_domain(self): + res = self._call_doc("/nonexistent") + self.assertEqual(res.status_code, 404) + + def test_dispatch_code_snippet(self): + partner = self.env["res.partner"].create( + {"name": "Original Name", "ref": "SNIPPET_TEST"} + ) + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "update_name", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_code_snippet": ( + 'p = Model.search([("ref", "=", params["ref"])], limit=1)\n' + "if not p:\n" + ' raise exceptions.NotFound("Not found")\n' + 'p.write({"name": params["new_name"]})\n' + 'result = {"ref": p.ref, "name": p.name}\n' + ), + } + ) + self.env["endpoint.json2.param"].create( + [ + { + "endpoint_id": endpoint.id, + "name": "ref", + "param_type": "string", + "required": True, + "sequence": 10, + }, + { + "endpoint_id": endpoint.id, + "name": "new_name", + "param_type": "string", + "required": True, + "sequence": 20, + }, + ] + ) + endpoint._handle_registry_sync() + res = self._call( + "test", "update_name", + {"ref": "SNIPPET_TEST", "new_name": "Updated Name"}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["name"], "Updated Name") + partner.invalidate_recordset() + self.assertEqual(partner.name, "Updated Name") + + def test_dispatch_code_snippet_missing_result(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "bad_snippet", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_code_snippet": "x = 1", + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "bad_snippet") + self.assertEqual(res.status_code, 500) + + def test_doc_excludes_restricted_endpoints(self): + group = self.env["res.groups"].create({"name": "Hidden Group"}) + self.env["endpoint.endpoint"].create( + { + "name": "hidden", + "route_group": "secret", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + "json2_group_ids": [(4, group.id)], + } + ) + res = self._call_doc() + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertNotIn("secret", data) diff --git a/endpoint_json2/views/endpoint_json2_view.xml b/endpoint_json2/views/endpoint_json2_view.xml new file mode 100644 index 0000000..e9244cc --- /dev/null +++ b/endpoint_json2/views/endpoint_json2_view.xml @@ -0,0 +1,57 @@ + + + + + endpoint.endpoint.json2.form + endpoint.endpoint + + + + exec_mode == 'json2' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From c47d1a0107a9110ae8d355b0f82c5b3b4122a9f8 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sat, 23 May 2026 15:20:39 +0000 Subject: [PATCH 02/16] fixup! --- endpoint_json2/controllers/main.py | 11 +- endpoint_json2/demo/endpoint_json2_demo.xml | 75 ++++++--- endpoint_json2/models/endpoint_mixin.py | 154 ++++++++++++++++-- endpoint_json2/readme/CONFIGURE.md | 45 +++++ endpoint_json2/readme/CONTRIBUTORS.md | 3 +- endpoint_json2/readme/DESCRIPTION.md | 2 +- endpoint_json2/readme/USAGE.md | 28 ++++ endpoint_json2/tests/test_endpoint_json2.py | 74 ++++++++- .../tests/test_endpoint_json2_controller.py | 7 +- endpoint_json2/views/endpoint_json2_view.xml | 14 +- 10 files changed, 355 insertions(+), 58 deletions(-) create mode 100644 endpoint_json2/readme/CONFIGURE.md create mode 100644 endpoint_json2/readme/USAGE.md diff --git a/endpoint_json2/controllers/main.py b/endpoint_json2/controllers/main.py index c3c9502..38e4bb3 100644 --- a/endpoint_json2/controllers/main.py +++ b/endpoint_json2/controllers/main.py @@ -9,7 +9,7 @@ class EndpointJson2DocController(http.Controller): @http.route( - "/json2/endpoint/doc", + "/json2/doc", methods=["GET"], auth="bearer", type="http", @@ -24,7 +24,7 @@ def doc_index(self): return request.make_json_response(result) @http.route( - "/json2/endpoint/doc/", + "/json2/doc/", methods=["GET"], auth="bearer", type="http", @@ -32,9 +32,7 @@ def doc_index(self): save_session=False, ) def doc_domain(self, route_group): - endpoints = self._get_accessible_endpoints( - [("route_group", "=", route_group)] - ) + endpoints = self._get_accessible_endpoints([("route_group", "=", route_group)]) if not endpoints: raise NotFound(f"No endpoints found for domain {route_group!r}") return request.make_json_response( @@ -46,8 +44,7 @@ def _get_accessible_endpoints(self, extra_domain=None): all_endpoints = request.env["endpoint.endpoint"].sudo().search(domain) user = request.env.user return all_endpoints.filtered( - lambda ep: not ep.json2_group_ids - or (ep.json2_group_ids & user.groups_id) + lambda ep: not ep.json2_group_ids or (ep.json2_group_ids & user.group_ids) ) @staticmethod diff --git a/endpoint_json2/demo/endpoint_json2_demo.xml b/endpoint_json2/demo/endpoint_json2_demo.xml index 59c5cab..8100932 100644 --- a/endpoint_json2/demo/endpoint_json2_demo.xml +++ b/endpoint_json2/demo/endpoint_json2_demo.xml @@ -5,7 +5,7 @@ get_partners - /json2/endpoint/contacts/get_partners + /json2/contacts/get_partners contacts json2 POST @@ -16,7 +16,9 @@ >Return partner records matching the given domain. search_read - name,email,phone,city,country_id + name,email,phone,city,country_id.name,write_date [["active", "=", true]] @@ -46,7 +48,7 @@ update_partner_name - /json2/endpoint/contacts/update_partner_name + /json2/contacts/update_partner_name contacts json2 POST @@ -65,10 +67,7 @@ partner.write({"name": params["new_name"]}) result = {"ref": partner.ref, "name": partner.name} - + ref string @@ -83,29 +82,59 @@ result = {"ref": partner.ref, "name": partner.name} 20 - - - get_countries - /json2/endpoint/reference/get_countries - reference + + + create_portal_user + /json2/contacts/create_portal_user + contacts json2 POST application/json bearer Return country records. - - search_read - name,code,phone_code - [] + >Create a portal user with the given name and email (code snippet example). + + login,name + +existing = Model.search([("login", "=", params["email"])], limit=1) +if existing: + raise exceptions.BadRequest("User already exists: " + params["email"]) +partner_vals = {"name": params["name"], "email": params["email"]} +if params.get("company_name"): + partner_vals["company_name"] = params["company_name"] +partner = env["res.partner"].create(partner_vals) +group_portal = env.ref("base.group_portal") +user = Model.create({ + "partner_id": partner.id, + "login": params["email"], + "group_ids": [Command.set([group_portal.id])], +}) +result = {"id": user.id, "login": user.login, "partner_id": partner.id} + - - - domain - list - - [] + + + name + string + 10 + + + email + string + + 20 + + + + company_name + string + + 30 + diff --git a/endpoint_json2/models/endpoint_mixin.py b/endpoint_json2/models/endpoint_mixin.py index 626e5cd..0cd33d3 100644 --- a/endpoint_json2/models/endpoint_mixin.py +++ b/endpoint_json2/models/endpoint_mixin.py @@ -2,10 +2,11 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import json +from datetime import date, datetime import werkzeug -from odoo import api, fields, models +from odoo import Command, api, fields, models from odoo.exceptions import AccessError, ValidationError from odoo.service.model import get_public_method from odoo.tools.safe_eval import json as safe_json @@ -26,7 +27,7 @@ class EndpointMixin(models.AbstractModel): json2_model_id = fields.Many2one( "ir.model", - string="Model", + string="Target Model", ondelete="cascade", domain=[("transient", "=", False)], ) @@ -45,6 +46,8 @@ class EndpointMixin(models.AbstractModel): json2_allowed_fields = fields.Char( string="Allowed Fields", help="Comma-separated list of field names the API may return. " + "Use dotted notation (e.g. country_id.name, tag_ids.name) to include " + "specific fields from relational fields (Many2one, Many2many, One2many). " "Leave empty to allow all fields.", ) json2_default_domain = fields.Char( @@ -64,9 +67,9 @@ class EndpointMixin(models.AbstractModel): string="Parameters", ) json2_code_snippet = fields.Text( - string="Code Snippet", + string="JSON2 Code Snippet", help="Optional Python code executed instead of the model method. " - "Available variables: Model, params, env, json, exceptions. " + "Available variables: Model, params, env, Command, json, exceptions. " "Use record.write({...}) for updates. " "Set the result in the 'result' variable.", ) @@ -78,7 +81,7 @@ def _selection_exec_mode(self): def _compute_route(self): for rec in self: if rec.exec_mode == "json2" and rec.route_group and rec.name: - rec.route = f"/json2/endpoint/{rec.route_group}/{rec.name}" + rec.route = f"/json2/{rec.route_group}/{rec.name}" else: rec.route = rec._clean_route() @@ -119,8 +122,7 @@ def _check_json2_request_settings(self): if rec.request_content_type != "application/json": raise ValidationError( self.env._( - "JSON2-RPC endpoints must use 'application/json' " - "content type." + "JSON2-RPC endpoints must use 'application/json' content type." ) ) @@ -129,9 +131,7 @@ def _check_json2_method(self): for rec in self: if rec.json2_method and rec.json2_method.startswith("_"): raise ValidationError( - self.env._( - "Private methods (starting with '_') cannot be exposed." - ) + self.env._("Private methods (starting with '_') cannot be exposed.") ) @api.constrains("json2_default_domain") @@ -157,7 +157,26 @@ def _check_json2_allowed_fields(self): continue Model = self.env[rec.json2_model_name] field_names = [f.strip() for f in rec.json2_allowed_fields.split(",")] - invalid = [f for f in field_names if f not in Model._fields] + invalid = [] + for f in field_names: + if "." in f: + base, sub = f.split(".", 1) + if base not in Model._fields: + invalid.append(f) + elif Model._fields[base].type not in ( + "many2one", + "many2many", + "one2many", + ): + invalid.append(f) + else: + comodel = Model._fields[base].comodel_name + if comodel not in self.env: + invalid.append(f) + elif sub not in self.env[comodel]._fields: + invalid.append(f) + elif f not in Model._fields: + invalid.append(f) if invalid: raise ValidationError( self.env._( @@ -177,6 +196,10 @@ def _handle_exec__json2(self, request): default_domain = json.loads(self.json2_default_domain or "[]") if default_domain: params["domain"] = default_domain + (params.get("domain") or []) + allowed = self._json2_get_allowed_field_list() + dotted_map = self._json2_parse_dotted_fields(allowed) + if dotted_map and params.get("fields"): + params["fields"] = list(set(params["fields"]) | dotted_map.keys()) if self.json2_code_snippet: result = self._json2_exec_code_snippet(Model, params) else: @@ -185,8 +208,10 @@ def _handle_exec__json2(self, request): except (AttributeError, AccessError) as exc: raise werkzeug.exceptions.NotFound(str(exc)) from exc result = method(Model, **params) - allowed = self._json2_get_allowed_field_list() + if dotted_map: + result = self._json2_resolve_dotted_fields(Model.env, result, dotted_map) result = self._json2_filter_result(result, allowed) + result = self._json2_serialize_values(result) return {"payload": result} def _json2_exec_code_snippet(self, Model, params): @@ -194,11 +219,18 @@ def _json2_exec_code_snippet(self, Model, params): "Model": Model, "params": params, "env": Model.env, + "Command": Command, "json": safe_json, - "exceptions": wrap_module(werkzeug.exceptions, [ - "BadRequest", "Forbidden", "NotFound", - "UnprocessableEntity", "InternalServerError", - ]), + "exceptions": wrap_module( + werkzeug.exceptions, + [ + "BadRequest", + "Forbidden", + "NotFound", + "UnprocessableEntity", + "InternalServerError", + ], + ), } safe_eval(self.json2_code_snippet, eval_ctx, mode="exec") if "result" not in eval_ctx: @@ -210,7 +242,7 @@ def _json2_exec_code_snippet(self, Model, params): def _json2_check_group_access(self, request): if not self.json2_group_ids: return - if not (self.json2_group_ids & request.env.user.groups_id): + if not (self.json2_group_ids & request.env.user.group_ids): raise werkzeug.exceptions.Forbidden( "User does not belong to any allowed group" ) @@ -251,6 +283,94 @@ def _json2_get_allowed_field_list(self): return [] return [f.strip() for f in self.json2_allowed_fields.split(",")] + @staticmethod + def _json2_parse_dotted_fields(allowed): + dotted = {} + for f in allowed: + if "." in f: + base, sub = f.split(".", 1) + dotted.setdefault(base, []).append(sub) + return dotted + + def _json2_resolve_dotted_fields(self, env, result, dotted_map): + rows = ( + result + if isinstance(result, list) + else [result] + if isinstance(result, dict) + else [] + ) + if not rows: + return result + Model = env[self.json2_model_name] + for base_field, sub_fields in dotted_map.items(): + field_def = Model._fields.get(base_field) + if not field_def or field_def.type not in ( + "many2one", + "many2many", + "one2many", + ): + continue + is_x2many = field_def.type != "many2one" + ids = set() + for row in rows: + if isinstance(row, dict): + ids.update(self._json2_extract_rel_ids(row.get(base_field))) + if ids: + related = { + r["id"]: r + for r in env[field_def.comodel_name] + .sudo() + .search_read([("id", "in", list(ids))], fields=sub_fields) + } + else: + related = {} + for row in rows: + if not isinstance(row, dict): + continue + row_ids = self._json2_extract_rel_ids(row.get(base_field)) + if is_x2many: + recs = [related[i] for i in row_ids if i in related] + for sub in sub_fields: + row[f"{base_field}.{sub}"] = [r.get(sub, False) for r in recs] + else: + rec = related.get(row_ids[0], {}) if row_ids else {} + for sub in sub_fields: + row[f"{base_field}.{sub}"] = rec.get(sub, False) + return result + + @staticmethod + def _json2_extract_rel_ids(val): + if not val: + return [] + if isinstance(val, int): + return [val] + if isinstance(val, (list, tuple)): + if val and isinstance(val[0], int): + if len(val) == 2 and isinstance(val[1], str): + return [val[0]] + return list(val) + if isinstance(val, dict): + rec_id = val.get("id") + return [rec_id] if rec_id else [] + return [] + + @staticmethod + def _json2_serialize_value(val): + if isinstance(val, datetime): + return val.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(val, date): + return val.isoformat() + return val + + @classmethod + def _json2_serialize_values(cls, result): + if isinstance(result, list): + return [cls._json2_serialize_values(item) for item in result] + if isinstance(result, dict): + return {k: cls._json2_serialize_value(v) for k, v in result.items()} + return cls._json2_serialize_value(result) + @staticmethod def _json2_filter_result(result, allowed_fields): if not allowed_fields: diff --git a/endpoint_json2/readme/CONFIGURE.md b/endpoint_json2/readme/CONFIGURE.md new file mode 100644 index 0000000..7dc124a --- /dev/null +++ b/endpoint_json2/readme/CONFIGURE.md @@ -0,0 +1,45 @@ +Go to *Settings > Technical > Endpoints* and create a new endpoint with +**Exec Mode** set to **JSON2-RPC**. + +### Basic Setup + +- **Model**: The Odoo model to operate on (e.g. `res.partner`). +- **Method**: A public model method (e.g. `search_read`). Alternatively, + provide a **Code Snippet** for custom logic — these two fields are + mutually exclusive. +- **Allowed Fields**: Comma-separated list of fields the API may return. + Use dotted notation for relational fields (e.g. + `name,email,country_id.name,category_id.name`). Dotted fields work + with Many2one, Many2many, and One2many relations. Leave empty to allow + all fields. +- **Default Domain**: A JSON-formatted domain filter applied to every + request (e.g. `[["active", "=", true]]`). +- **Parameters**: Define named parameters with types, defaults, and + required flags. These are validated before the method is called. + +### Access Control + +All endpoint execution is wrapped in `sudo()`, allowing API users to +operate with minimal Odoo privileges. Access is controlled at two levels: + +- **Auth Type**: Set to **Bearer** to require an API key for + authentication. +- **Allowed Groups**: Restrict endpoint access to specific user groups. + Create integration-specific groups (e.g. "Hospital System", "WMS") and + assign them to the corresponding API users. Each endpoint declares + which groups may call it. Leave empty to allow any authenticated user. +- **Allowed Fields**: Controls which data fields are exposed in the + response, regardless of what the underlying model method returns. + +### Code Snippets + +For operations that go beyond a single model method call, use a code +snippet instead of the method field. Available variables: + +- `Model`: The target model (with `sudo()`). +- `params`: Validated parameters from the request. +- `env`: The Odoo environment. +- `Command`: Odoo's `Command` helper for relational field writes. +- `exceptions`: Werkzeug exceptions (`BadRequest`, `NotFound`, etc.). + +The snippet must set a `result` variable with the response data. diff --git a/endpoint_json2/readme/CONTRIBUTORS.md b/endpoint_json2/readme/CONTRIBUTORS.md index 2e5eff5..5ce4a82 100644 --- a/endpoint_json2/readme/CONTRIBUTORS.md +++ b/endpoint_json2/readme/CONTRIBUTORS.md @@ -1 +1,2 @@ -- Yoshi Tashiro (Quartile) \ +- Quartile \<\> + - Yoshi Tashiro diff --git a/endpoint_json2/readme/DESCRIPTION.md b/endpoint_json2/readme/DESCRIPTION.md index 31b3605..c3d0657 100644 --- a/endpoint_json2/readme/DESCRIPTION.md +++ b/endpoint_json2/readme/DESCRIPTION.md @@ -4,4 +4,4 @@ model, method, and parameters — the module handles dispatch, parameter validation, access control, and result filtering. Also provides auto-generated API documentation endpoints at -`/json2/endpoint/doc`. +`/json2/doc`. diff --git a/endpoint_json2/readme/USAGE.md b/endpoint_json2/readme/USAGE.md new file mode 100644 index 0000000..a1e7da6 --- /dev/null +++ b/endpoint_json2/readme/USAGE.md @@ -0,0 +1,28 @@ +### Calling an Endpoint + +Send a POST request with a JSON body to the endpoint's route: + +```bash +curl -X POST https://your-odoo.com/json2/contacts/get_partners \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{"domain": [["is_company", "=", true]], "limit": 10}' +``` + +### Incremental Sync + +To fetch only records modified since a given timestamp, include a +`write_date` filter in the domain and add `write_date` to the allowed +fields: + +```json +{"domain": [["write_date", ">=", "2026-05-23 00:00:00"]]} +``` + +Use the latest `write_date` from the response as the starting point for +the next sync to avoid clock drift between client and server. + +### API Documentation + +Auto-generated documentation for all JSON2 endpoints is available at +`/json2/doc`. diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py index dbb7014..5e52908 100644 --- a/endpoint_json2/tests/test_endpoint_json2.py +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -14,7 +14,7 @@ def test_create_endpoint(self): ) def test_route_auto_computed(self): - self.assertEqual(self.endpoint.route, "/json2/endpoint/contacts/get_partners") + self.assertEqual(self.endpoint.route, "/json2/contacts/get_partners") def test_private_method_rejected(self): with self.assertRaises(ValidationError): @@ -149,3 +149,75 @@ def test_validate_snippet_without_method_ok(self): } ) self.assertTrue(ep.json2_code_snippet) + + def test_dotted_allowed_fields_valid(self): + self.endpoint.json2_allowed_fields = "name,country_id.name" + self.assertEqual( + self.endpoint._json2_get_allowed_field_list(), + ["name", "country_id.name"], + ) + + def test_dotted_allowed_fields_invalid_base(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_allowed_fields = "name,nonexistent_id.name" + + def test_dotted_allowed_fields_non_m2o(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_allowed_fields = "name,email.something" + + def test_dotted_allowed_fields_invalid_sub(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_allowed_fields = "name,country_id.nonexistent" + + def test_parse_dotted_fields(self): + allowed = ["name", "country_id.name", "country_id.code", "email"] + dotted = self.endpoint._json2_parse_dotted_fields(allowed) + self.assertEqual(dotted, {"country_id": ["name", "code"]}) + + def test_resolve_dotted_fields(self): + country = self.env["res.country"].search([("code", "=", "JP")], limit=1) + self.assertTrue(country) + result = [ + {"id": 1, "name": "Test", "country_id": (country.id, country.display_name)}, + {"id": 2, "name": "Test2", "country_id": False}, + ] + dotted_map = {"country_id": ["name", "code"]} + self.endpoint._json2_resolve_dotted_fields(self.env, result, dotted_map) + self.assertEqual(result[0]["country_id.name"], country.name) + self.assertEqual(result[0]["country_id.code"], "JP") + self.assertFalse(result[1]["country_id.name"]) + self.assertFalse(result[1]["country_id.code"]) + + def test_dotted_allowed_fields_m2m_valid(self): + self.endpoint.json2_allowed_fields = "name,category_id.name" + self.assertEqual( + self.endpoint._json2_get_allowed_field_list(), + ["name", "category_id.name"], + ) + + def test_resolve_dotted_fields_x2many(self): + tags = self.env["res.partner.category"].search([], limit=2) + if len(tags) < 2: + tags = self.env["res.partner.category"].create( + [{"name": "TagA"}, {"name": "TagB"}] + ) + result = [ + {"id": 1, "name": "Test", "category_id": tags.ids}, + {"id": 2, "name": "Test2", "category_id": []}, + ] + dotted_map = {"category_id": ["name"]} + self.endpoint._json2_resolve_dotted_fields(self.env, result, dotted_map) + self.assertEqual(result[0]["category_id.name"], tags.mapped("name")) + self.assertEqual(result[1]["category_id.name"], []) + + def test_filter_excludes_base_when_only_dotted(self): + result = { + "name": "Test", + "country_id": (1, "Japan"), + "country_id.name": "Japan", + } + filtered = self.endpoint._json2_filter_result( + result, ["name", "country_id.name"] + ) + self.assertEqual(filtered, {"name": "Test", "country_id.name": "Japan"}) + self.assertNotIn("country_id", filtered) diff --git a/endpoint_json2/tests/test_endpoint_json2_controller.py b/endpoint_json2/tests/test_endpoint_json2_controller.py index 27ad51a..b6e2f8c 100644 --- a/endpoint_json2/tests/test_endpoint_json2_controller.py +++ b/endpoint_json2/tests/test_endpoint_json2_controller.py @@ -83,7 +83,7 @@ def tearDown(self): super().tearDown() def _call(self, route_group, endpoint_name, payload=None): - url = f"/json2/endpoint/{route_group}/{endpoint_name}" + url = f"/json2/{route_group}/{endpoint_name}" return self.url_open( url, data=json.dumps(payload or {}), @@ -91,7 +91,7 @@ def _call(self, route_group, endpoint_name, payload=None): ) def _call_doc(self, path=""): - url = f"/json2/endpoint/doc{path}" + url = f"/json2/doc{path}" return self.url_open( url, headers=self.bearer, @@ -285,7 +285,8 @@ def test_dispatch_code_snippet(self): ) endpoint._handle_registry_sync() res = self._call( - "test", "update_name", + "test", + "update_name", {"ref": "SNIPPET_TEST", "new_name": "Updated Name"}, ) self.assertEqual(res.status_code, 200) diff --git a/endpoint_json2/views/endpoint_json2_view.xml b/endpoint_json2/views/endpoint_json2_view.xml index e9244cc..e4956cb 100644 --- a/endpoint_json2/views/endpoint_json2_view.xml +++ b/endpoint_json2/views/endpoint_json2_view.xml @@ -10,6 +10,11 @@ exec_mode == 'json2' + + + exec_mode == 'json2' or not request_content_schema_applicable + + - + + - - - - + From 88228f4baca2737be5e7fc5c192de7769d8bcfa0 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 24 May 2026 08:06:09 +0000 Subject: [PATCH 03/16] fixup! --- endpoint_json2/README.rst | 200 +++++++ endpoint_json2/__init__.py | 3 +- endpoint_json2/__manifest__.py | 7 +- endpoint_json2/controllers/main.py | 2 +- endpoint_json2/demo/endpoint_json2_demo.xml | 17 +- endpoint_json2/models/__init__.py | 3 +- endpoint_json2/models/endpoint_json2_param.py | 2 +- endpoint_json2/models/endpoint_mixin.py | 76 ++- endpoint_json2/readme/CONFIGURE.md | 24 +- endpoint_json2/readme/USAGE.md | 8 +- endpoint_json2/static/description/icon.png | Bin 9455 -> 0 bytes endpoint_json2/static/description/index.html | 545 ++++++++++++++++++ endpoint_json2/tests/__init__.py | 3 +- endpoint_json2/tests/common.py | 4 +- endpoint_json2/tests/test_endpoint_json2.py | 62 +- .../tests/test_endpoint_json2_controller.py | 4 +- endpoint_json2/views/endpoint_json2_view.xml | 33 +- 17 files changed, 897 insertions(+), 96 deletions(-) create mode 100644 endpoint_json2/README.rst delete mode 100644 endpoint_json2/static/description/icon.png create mode 100644 endpoint_json2/static/description/index.html diff --git a/endpoint_json2/README.rst b/endpoint_json2/README.rst new file mode 100644 index 0000000..33334b3 --- /dev/null +++ b/endpoint_json2/README.rst @@ -0,0 +1,200 @@ +============== +Endpoint JSON2 +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5472d3ec0fc0bd5efbf0ba22d9a525da9789d0465823daa443bef74f3157193b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github + :target: https://github.com/OCA/web-api/tree/19.0/endpoint_json2 + :alt: OCA/web-api +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-api-19-0/web-api-19-0-endpoint_json2 + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds ``exec_mode="json2"`` to the endpoint framework, enabling +declarative JSON2-RPC endpoint configuration. Instead of writing code +snippets, select a model, method, and parameters — the module handles +dispatch, parameter validation, access control, and result filtering. + +Also provides auto-generated API documentation endpoints at +``/json2/doc``. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to *Settings > Technical > Endpoints* and create a new endpoint with +**Exec Mode** set to **JSON2-RPC**. + +Basic Setup +----------- + +- **Model**: The Odoo model to operate on (e.g. ``res.partner``). + +- **Method**: A public model method (e.g. ``search_read``). + Alternatively, provide a **Code Snippet** for custom logic — these + two fields are mutually exclusive. + +- **Response Fields**: One field per line. Optionally follow with an + alias to rename the key in the response. Use dotted notation for + relational fields (Many2one, Many2many, One2many). Leave empty to + return all fields. Example: + + :: + + name + email + country_id.name country + write_date last_modified + +- **Default Domain**: A JSON-formatted domain filter applied to every + request (e.g. ``[["active", "=", true]]``). + +- **Parameters**: Define named parameters with types, defaults, and + required flags. These are validated before the method is called. + +Access Control +-------------- + +All endpoint execution is wrapped in ``sudo()``, allowing API users to +operate with minimal Odoo privileges. Access is controlled at two +levels: + +- **Auth Type**: Set to **Bearer** to require an API key for + authentication. +- **Allowed Groups**: Restrict endpoint access to specific user groups. + Create integration-specific groups (e.g. "Hospital System", "WMS") + and assign them to the corresponding API users. Each endpoint + declares which groups may call it. Leave empty to allow any + authenticated user. +- **Response Fields**: Controls which data fields are included in the + response, regardless of what the underlying model method returns. + +Code Snippets +------------- + +For operations that go beyond a single model method call, use a code +snippet instead of the method field. Available variables: + +- ``Model``: The target model (with ``sudo()``). +- ``params``: Validated parameters from the request. +- ``env``: The Odoo environment. +- ``Command``: Odoo's ``Command`` helper for relational field writes. +- ``exceptions``: Werkzeug exceptions (``BadRequest``, ``NotFound``, + etc.). + +The snippet must set a ``result`` variable with the response data. + +Usage +===== + +Calling an Endpoint +------------------- + +Send a POST request with a JSON body to the endpoint's route: + +.. code:: bash + + curl -X POST https://your-odoo.com/json2/contacts/get_partners \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{"domain": [["is_company", "=", true]], "limit": 10}' + +Incremental Sync +---------------- + +To fetch only records modified since a given timestamp, include a +``write_date`` filter in the domain and add ``write_date`` to the +response fields: + +.. code:: json + + {"domain": [["write_date", ">=", "2026-05-23 00:00:00"]]} + +Use the latest ``write_date`` from the response as the starting point +for the next sync to avoid clock drift between client and server. + +API Documentation +----------------- + +Auto-generated documentation for all JSON2 endpoints is available at +``/json2/doc``. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- Quartile + + - Yoshi Tashiro + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro +.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px + :target: https://github.com/aungkokolin1997 + :alt: aungkokolin1997 + +Current `maintainers `__: + +|maintainer-yostashiro| |maintainer-aungkokolin1997| + +This module is part of the `OCA/web-api `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/endpoint_json2/__init__.py b/endpoint_json2/__init__.py index 72d3ea6..91c5580 100644 --- a/endpoint_json2/__init__.py +++ b/endpoint_json2/__init__.py @@ -1 +1,2 @@ -from . import controllers, models +from . import controllers +from . import models diff --git a/endpoint_json2/__manifest__.py b/endpoint_json2/__manifest__.py index e992720..fb21597 100644 --- a/endpoint_json2/__manifest__.py +++ b/endpoint_json2/__manifest__.py @@ -1,11 +1,11 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). { "name": "Endpoint JSON2", - "summary": "Declarative JSON2-RPC endpoints on the endpoint stack.", + "summary": "Declarative JSON2-RPC endpoints on the endpoint stack", "version": "19.0.1.0.0", "license": "LGPL-3", - "development_status": "Beta", + "development_status": "Alpha", "author": "Quartile, Odoo Community Association (OCA)", "website": "https://github.com/OCA/web-api", "category": "Technical", @@ -16,4 +16,5 @@ ], "demo": ["demo/endpoint_json2_demo.xml"], "installable": True, + "maintainers": ["yostashiro", "aungkokolin1997"], } diff --git a/endpoint_json2/controllers/main.py b/endpoint_json2/controllers/main.py index 38e4bb3..45f4766 100644 --- a/endpoint_json2/controllers/main.py +++ b/endpoint_json2/controllers/main.py @@ -1,5 +1,5 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). from werkzeug.exceptions import NotFound diff --git a/endpoint_json2/demo/endpoint_json2_demo.xml b/endpoint_json2/demo/endpoint_json2_demo.xml index 8100932..e6fc313 100644 --- a/endpoint_json2/demo/endpoint_json2_demo.xml +++ b/endpoint_json2/demo/endpoint_json2_demo.xml @@ -1,6 +1,4 @@ - @@ -16,9 +14,12 @@ >Return partner records matching the given domain. search_read - name,email,phone,city,country_id.name,write_date + name +email +phone +city +country_id.name country +write_date [["active", "=", true]] @@ -58,7 +59,8 @@ name="json2_description" >Update a partner's name by ref (code snippet example). - ref,name + ref +name partner = Model.search([("ref", "=", params["ref"])], limit=1) if not partner: @@ -95,7 +97,8 @@ result = {"ref": partner.ref, "name": partner.name} name="json2_description" >Create a portal user with the given name and email (code snippet example). - login,name + login +name existing = Model.search([("login", "=", params["email"])], limit=1) if existing: diff --git a/endpoint_json2/models/__init__.py b/endpoint_json2/models/__init__.py index d6b9400..3a15b29 100644 --- a/endpoint_json2/models/__init__.py +++ b/endpoint_json2/models/__init__.py @@ -1 +1,2 @@ -from . import endpoint_json2_param, endpoint_mixin +from . import endpoint_json2_param +from . import endpoint_mixin diff --git a/endpoint_json2/models/endpoint_json2_param.py b/endpoint_json2/models/endpoint_json2_param.py index 905c2b3..bcc58b7 100644 --- a/endpoint_json2/models/endpoint_json2_param.py +++ b/endpoint_json2/models/endpoint_json2_param.py @@ -1,5 +1,5 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). import json diff --git a/endpoint_json2/models/endpoint_mixin.py b/endpoint_json2/models/endpoint_mixin.py index 0cd33d3..9132fb1 100644 --- a/endpoint_json2/models/endpoint_mixin.py +++ b/endpoint_json2/models/endpoint_mixin.py @@ -1,5 +1,5 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). import json from datetime import date, datetime @@ -43,12 +43,15 @@ class EndpointMixin(models.AbstractModel): string="Description", help="Displayed in the API documentation endpoint.", ) - json2_allowed_fields = fields.Char( - string="Allowed Fields", - help="Comma-separated list of field names the API may return. " - "Use dotted notation (e.g. country_id.name, tag_ids.name) to include " - "specific fields from relational fields (Many2one, Many2many, One2many). " - "Leave empty to allow all fields.", + json2_response_fields = fields.Text( + string="Response Fields", + help="One field per line. Optionally add an alias to rename in output.\n" + "Use dotted notation for relational fields (Many2one, Many2many, One2many).\n" + "Leave empty to return all fields.\n\n" + "Examples:\n" + " name\n" + " country_id.name country\n" + " write_date last_modified", ) json2_default_domain = fields.Char( string="Default Domain", @@ -148,15 +151,15 @@ def _check_json2_default_domain(self): self.env._("Default domain must be a valid JSON list.") ) from None - @api.constrains("json2_allowed_fields", "json2_model_id") - def _check_json2_allowed_fields(self): + @api.constrains("json2_response_fields", "json2_model_id") + def _check_json2_response_fields(self): for rec in self: - if not rec.json2_allowed_fields or not rec.json2_model_name: + if not rec.json2_response_fields or not rec.json2_model_name: continue if rec.json2_model_name not in self.env: continue Model = self.env[rec.json2_model_name] - field_names = [f.strip() for f in rec.json2_allowed_fields.split(",")] + field_names, _aliases = rec._json2_parse_response_fields() invalid = [] for f in field_names: if "." in f: @@ -196,8 +199,8 @@ def _handle_exec__json2(self, request): default_domain = json.loads(self.json2_default_domain or "[]") if default_domain: params["domain"] = default_domain + (params.get("domain") or []) - allowed = self._json2_get_allowed_field_list() - dotted_map = self._json2_parse_dotted_fields(allowed) + response_fields, aliases = self._json2_parse_response_fields() + dotted_map = self._json2_parse_dotted_fields(response_fields) if dotted_map and params.get("fields"): params["fields"] = list(set(params["fields"]) | dotted_map.keys()) if self.json2_code_snippet: @@ -210,7 +213,9 @@ def _handle_exec__json2(self, request): result = method(Model, **params) if dotted_map: result = self._json2_resolve_dotted_fields(Model.env, result, dotted_map) - result = self._json2_filter_result(result, allowed) + 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} @@ -277,11 +282,25 @@ def _json2_check_param_type(value, expected_type): return isinstance(value, (int, float)) return isinstance(value, expected_type) - def _json2_get_allowed_field_list(self): + def _json2_parse_response_fields(self): + """Parse response fields text into a field list and alias map. + + Returns (fields, aliases) where aliases maps field_name -> alias. + """ self.ensure_one() - if not self.json2_allowed_fields: - return [] - return [f.strip() for f in self.json2_allowed_fields.split(",")] + if not self.json2_response_fields: + return [], {} + field_list = [] + aliases = {} + for line in self.json2_response_fields.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split() + field_list.append(parts[0]) + if len(parts) > 1: + aliases[parts[0]] = parts[1] + return field_list, aliases @staticmethod def _json2_parse_dotted_fields(allowed): @@ -372,16 +391,29 @@ def _json2_serialize_values(cls, result): return cls._json2_serialize_value(result) @staticmethod - def _json2_filter_result(result, allowed_fields): - if not allowed_fields: + def _json2_filter_result(result, response_fields): + if not response_fields: return result if isinstance(result, list): return [ - {k: v for k, v in row.items() if k in allowed_fields} + {k: v for k, v in row.items() if k in response_fields} if isinstance(row, dict) else row for row in result ] if isinstance(result, dict): - return {k: v for k, v in result.items() if k in allowed_fields} + return {k: v for k, v in result.items() if k in response_fields} + return result + + @staticmethod + def _json2_apply_aliases(result, aliases): + def _rename(row): + if not isinstance(row, dict): + return row + return {aliases.get(k, k): v for k, v in row.items()} + + if isinstance(result, list): + return [_rename(row) for row in result] + if isinstance(result, dict): + return _rename(result) return result diff --git a/endpoint_json2/readme/CONFIGURE.md b/endpoint_json2/readme/CONFIGURE.md index 7dc124a..19a59c0 100644 --- a/endpoint_json2/readme/CONFIGURE.md +++ b/endpoint_json2/readme/CONFIGURE.md @@ -1,23 +1,29 @@ Go to *Settings > Technical > Endpoints* and create a new endpoint with **Exec Mode** set to **JSON2-RPC**. -### Basic Setup +## Basic Setup - **Model**: The Odoo model to operate on (e.g. `res.partner`). - **Method**: A public model method (e.g. `search_read`). Alternatively, provide a **Code Snippet** for custom logic — these two fields are mutually exclusive. -- **Allowed Fields**: Comma-separated list of fields the API may return. - Use dotted notation for relational fields (e.g. - `name,email,country_id.name,category_id.name`). Dotted fields work - with Many2one, Many2many, and One2many relations. Leave empty to allow - all fields. +- **Response Fields**: One field per line. Optionally follow with an + alias to rename the key in the response. Use dotted notation for + relational fields (Many2one, Many2many, One2many). Leave empty to + return all fields. Example: + + ``` + name + email + country_id.name country + write_date last_modified + ``` - **Default Domain**: A JSON-formatted domain filter applied to every request (e.g. `[["active", "=", true]]`). - **Parameters**: Define named parameters with types, defaults, and required flags. These are validated before the method is called. -### Access Control +## Access Control All endpoint execution is wrapped in `sudo()`, allowing API users to operate with minimal Odoo privileges. Access is controlled at two levels: @@ -28,10 +34,10 @@ operate with minimal Odoo privileges. Access is controlled at two levels: Create integration-specific groups (e.g. "Hospital System", "WMS") and assign them to the corresponding API users. Each endpoint declares which groups may call it. Leave empty to allow any authenticated user. -- **Allowed Fields**: Controls which data fields are exposed in the +- **Response Fields**: Controls which data fields are included in the response, regardless of what the underlying model method returns. -### Code Snippets +## Code Snippets For operations that go beyond a single model method call, use a code snippet instead of the method field. Available variables: diff --git a/endpoint_json2/readme/USAGE.md b/endpoint_json2/readme/USAGE.md index a1e7da6..21c7408 100644 --- a/endpoint_json2/readme/USAGE.md +++ b/endpoint_json2/readme/USAGE.md @@ -1,4 +1,4 @@ -### Calling an Endpoint +## Calling an Endpoint Send a POST request with a JSON body to the endpoint's route: @@ -9,10 +9,10 @@ curl -X POST https://your-odoo.com/json2/contacts/get_partners \ -d '{"domain": [["is_company", "=", true]], "limit": 10}' ``` -### Incremental Sync +## Incremental Sync To fetch only records modified since a given timestamp, include a -`write_date` filter in the domain and add `write_date` to the allowed +`write_date` filter in the domain and add `write_date` to the response fields: ```json @@ -22,7 +22,7 @@ fields: Use the latest `write_date` from the response as the starting point for the next sync to avoid clock drift between client and server. -### API Documentation +## API Documentation Auto-generated documentation for all JSON2 endpoints is available at `/json2/doc`. diff --git a/endpoint_json2/static/description/icon.png b/endpoint_json2/static/description/icon.png deleted file mode 100644 index 3a0328b516c4980e8e44cdb63fd945757ddd132d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I diff --git a/endpoint_json2/static/description/index.html b/endpoint_json2/static/description/index.html new file mode 100644 index 0000000..e295f8a --- /dev/null +++ b/endpoint_json2/static/description/index.html @@ -0,0 +1,545 @@ + + + + + + +Endpoint JSON2 + + + +
+

Endpoint JSON2

+ + +

Alpha License: LGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

Adds exec_mode="json2" to the endpoint framework, enabling +declarative JSON2-RPC endpoint configuration. Instead of writing code +snippets, select a model, method, and parameters — the module handles +dispatch, parameter validation, access control, and result filtering.

+

Also provides auto-generated API documentation endpoints at +/json2/doc.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

Go to Settings > Technical > Endpoints and create a new endpoint with +Exec Mode set to JSON2-RPC.

+
+

Basic Setup

+
    +
  • Model: The Odoo model to operate on (e.g. res.partner).

    +
  • +
  • Method: A public model method (e.g. search_read). +Alternatively, provide a Code Snippet for custom logic — these +two fields are mutually exclusive.

    +
  • +
  • Response Fields: One field per line. Optionally follow with an +alias to rename the key in the response. Use dotted notation for +relational fields (Many2one, Many2many, One2many). Leave empty to +return all fields. Example:

    +
    +name
    +email
    +country_id.name country
    +write_date last_modified
    +
    +
  • +
  • Default Domain: A JSON-formatted domain filter applied to every +request (e.g. [["active", "=", true]]).

    +
  • +
  • Parameters: Define named parameters with types, defaults, and +required flags. These are validated before the method is called.

    +
  • +
+
+
+

Access Control

+

All endpoint execution is wrapped in sudo(), allowing API users to +operate with minimal Odoo privileges. Access is controlled at two +levels:

+
    +
  • Auth Type: Set to Bearer to require an API key for +authentication.
  • +
  • Allowed Groups: Restrict endpoint access to specific user groups. +Create integration-specific groups (e.g. “Hospital System”, “WMS”) +and assign them to the corresponding API users. Each endpoint +declares which groups may call it. Leave empty to allow any +authenticated user.
  • +
  • Response Fields: Controls which data fields are included in the +response, regardless of what the underlying model method returns.
  • +
+
+
+

Code Snippets

+

For operations that go beyond a single model method call, use a code +snippet instead of the method field. Available variables:

+
    +
  • Model: The target model (with sudo()).
  • +
  • params: Validated parameters from the request.
  • +
  • env: The Odoo environment.
  • +
  • Command: Odoo’s Command helper for relational field writes.
  • +
  • exceptions: Werkzeug exceptions (BadRequest, NotFound, +etc.).
  • +
+

The snippet must set a result variable with the response data.

+
+
+
+

Usage

+
+

Calling an Endpoint

+

Send a POST request with a JSON body to the endpoint’s route:

+
+curl -X POST https://your-odoo.com/json2/contacts/get_partners \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{"domain": [["is_company", "=", true]], "limit": 10}'
+
+
+
+

Incremental Sync

+

To fetch only records modified since a given timestamp, include a +write_date filter in the domain and add write_date to the +response fields:

+
+{"domain": [["write_date", ">=", "2026-05-23 00:00:00"]]}
+
+

Use the latest write_date from the response as the starting point +for the next sync to avoid clock drift between client and server.

+
+
+

API Documentation

+

Auto-generated documentation for all JSON2 endpoints is available at +/json2/doc.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

yostashiro aungkokolin1997

+

This module is part of the OCA/web-api project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/endpoint_json2/tests/__init__.py b/endpoint_json2/tests/__init__.py index bbd2ffc..ce073f5 100644 --- a/endpoint_json2/tests/__init__.py +++ b/endpoint_json2/tests/__init__.py @@ -1 +1,2 @@ -from . import test_endpoint_json2, test_endpoint_json2_controller +from . import test_endpoint_json2 +from . import test_endpoint_json2_controller diff --git a/endpoint_json2/tests/common.py b/endpoint_json2/tests/common.py index 4ba4a5a..e153f7c 100644 --- a/endpoint_json2/tests/common.py +++ b/endpoint_json2/tests/common.py @@ -1,5 +1,5 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). from odoo.tests.common import TransactionCase, tagged @@ -21,7 +21,7 @@ def setUpClass(cls): "json2_description": "Return partner records", "json2_model_id": cls.model_partner.id, "json2_method": "search_read", - "json2_allowed_fields": "name,email", + "json2_response_fields": "name\nemail", "json2_default_domain": "[]", } ) diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py index 5e52908..58475bf 100644 --- a/endpoint_json2/tests/test_endpoint_json2.py +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -1,5 +1,5 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). from odoo.exceptions import ValidationError @@ -9,9 +9,9 @@ class TestEndpointJson2(CommonEndpointJson2): def test_create_endpoint(self): self.assertEqual(self.endpoint.json2_model_name, "res.partner") - self.assertEqual( - self.endpoint._json2_get_allowed_field_list(), ["name", "email"] - ) + fields, aliases = self.endpoint._json2_parse_response_fields() + self.assertEqual(fields, ["name", "email"]) + self.assertEqual(aliases, {}) def test_route_auto_computed(self): self.assertEqual(self.endpoint.route, "/json2/contacts/get_partners") @@ -39,13 +39,15 @@ def test_domain_not_list(self): with self.assertRaises(ValidationError): self.endpoint.json2_default_domain = '{"key": "value"}' - def test_invalid_allowed_fields(self): + def test_invalid_response_fields(self): with self.assertRaises(ValidationError): - self.endpoint.json2_allowed_fields = "name,nonexistent_field" + self.endpoint.json2_response_fields = "name\nnonexistent_field" - def test_empty_allowed_fields(self): - self.endpoint.json2_allowed_fields = False - self.assertEqual(self.endpoint._json2_get_allowed_field_list(), []) + def test_empty_response_fields(self): + self.endpoint.json2_response_fields = False + fields, aliases = self.endpoint._json2_parse_response_fields() + self.assertEqual(fields, []) + self.assertEqual(aliases, {}) def test_param_creation(self): param = self.env["endpoint.json2.param"].create( @@ -75,6 +77,8 @@ def test_filter_result_dict(self): result = {"name": "Test", "email": "a@b.c", "phone": "123"} filtered = self.endpoint._json2_filter_result(result, ["name", "email"]) self.assertEqual(filtered, {"name": "Test", "email": "a@b.c"}) + aliased = self.endpoint._json2_apply_aliases(filtered, {"email": "mail"}) + self.assertEqual(aliased, {"name": "Test", "mail": "a@b.c"}) def test_filter_result_list(self): result = [ @@ -83,9 +87,12 @@ def test_filter_result_list(self): ] filtered = self.endpoint._json2_filter_result(result, ["name"]) self.assertEqual(filtered, [{"name": "A"}, {"name": "B"}]) + aliased = self.endpoint._json2_apply_aliases(filtered, {"name": "label"}) + self.assertEqual(aliased, [{"label": "A"}, {"label": "B"}]) def test_filter_result_passthrough(self): self.assertEqual(self.endpoint._json2_filter_result(42, ["name"]), 42) + self.assertEqual(self.endpoint._json2_apply_aliases(42, {"name": "n"}), 42) def test_filter_result_no_filter(self): result = {"name": "Test", "phone": "123"} @@ -150,24 +157,23 @@ def test_validate_snippet_without_method_ok(self): ) self.assertTrue(ep.json2_code_snippet) - def test_dotted_allowed_fields_valid(self): - self.endpoint.json2_allowed_fields = "name,country_id.name" - self.assertEqual( - self.endpoint._json2_get_allowed_field_list(), - ["name", "country_id.name"], - ) + def test_dotted_response_fields_valid(self): + self.endpoint.json2_response_fields = "name\ncountry_id.name country" + fields, aliases = self.endpoint._json2_parse_response_fields() + self.assertEqual(fields, ["name", "country_id.name"]) + self.assertEqual(aliases, {"country_id.name": "country"}) - def test_dotted_allowed_fields_invalid_base(self): + def test_dotted_response_fields_invalid_base(self): with self.assertRaises(ValidationError): - self.endpoint.json2_allowed_fields = "name,nonexistent_id.name" + self.endpoint.json2_response_fields = "name\nnonexistent_id.name" - def test_dotted_allowed_fields_non_m2o(self): + def test_dotted_response_fields_non_m2o(self): with self.assertRaises(ValidationError): - self.endpoint.json2_allowed_fields = "name,email.something" + self.endpoint.json2_response_fields = "name\nemail.something" - def test_dotted_allowed_fields_invalid_sub(self): + def test_dotted_response_fields_invalid_sub(self): with self.assertRaises(ValidationError): - self.endpoint.json2_allowed_fields = "name,country_id.nonexistent" + self.endpoint.json2_response_fields = "name\ncountry_id.nonexistent" def test_parse_dotted_fields(self): allowed = ["name", "country_id.name", "country_id.code", "email"] @@ -188,12 +194,10 @@ def test_resolve_dotted_fields(self): self.assertFalse(result[1]["country_id.name"]) self.assertFalse(result[1]["country_id.code"]) - def test_dotted_allowed_fields_m2m_valid(self): - self.endpoint.json2_allowed_fields = "name,category_id.name" - self.assertEqual( - self.endpoint._json2_get_allowed_field_list(), - ["name", "category_id.name"], - ) + def test_dotted_response_fields_m2m_valid(self): + self.endpoint.json2_response_fields = "name\ncategory_id.name" + fields, _aliases = self.endpoint._json2_parse_response_fields() + self.assertEqual(fields, ["name", "category_id.name"]) def test_resolve_dotted_fields_x2many(self): tags = self.env["res.partner.category"].search([], limit=2) @@ -221,3 +225,7 @@ def test_filter_excludes_base_when_only_dotted(self): ) self.assertEqual(filtered, {"name": "Test", "country_id.name": "Japan"}) self.assertNotIn("country_id", filtered) + aliased = self.endpoint._json2_apply_aliases( + filtered, {"country_id.name": "country"} + ) + self.assertEqual(aliased, {"name": "Test", "country": "Japan"}) diff --git a/endpoint_json2/tests/test_endpoint_json2_controller.py b/endpoint_json2/tests/test_endpoint_json2_controller.py index b6e2f8c..0a33fc2 100644 --- a/endpoint_json2/tests/test_endpoint_json2_controller.py +++ b/endpoint_json2/tests/test_endpoint_json2_controller.py @@ -1,5 +1,5 @@ # Copyright 2026 Quartile (https://www.quartile.co) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). import json import os @@ -45,7 +45,7 @@ def setUpClass(cls): "json2_description": "Return partner records", "json2_model_id": cls.model_partner.id, "json2_method": "search_read", - "json2_allowed_fields": "name,email", + "json2_response_fields": "name\nemail", "json2_default_domain": '[["is_company", "=", true]]', } ) diff --git a/endpoint_json2/views/endpoint_json2_view.xml b/endpoint_json2/views/endpoint_json2_view.xml index e4956cb..7d1bf1a 100644 --- a/endpoint_json2/views/endpoint_json2_view.xml +++ b/endpoint_json2/views/endpoint_json2_view.xml @@ -1,6 +1,4 @@ - endpoint.endpoint.json2.form @@ -22,28 +20,20 @@ invisible="exec_mode != 'json2'" > - + - - - - + + + - - - - + @@ -54,6 +44,19 @@ + + + + + + + + +
From 9d6efe85958c1717d6cb731abae7a3333d33c4da Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 24 May 2026 08:20:35 +0000 Subject: [PATCH 04/16] fixup! --- .../tests/test_endpoint_json2_controller.py | 96 ++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/endpoint_json2/tests/test_endpoint_json2_controller.py b/endpoint_json2/tests/test_endpoint_json2_controller.py index 0a33fc2..d3c8bc0 100644 --- a/endpoint_json2/tests/test_endpoint_json2_controller.py +++ b/endpoint_json2/tests/test_endpoint_json2_controller.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from unittest import skipIf +from odoo import Command from odoo.tests import new_test_user, tagged from odoo.tests.common import HttpCase @@ -105,6 +106,7 @@ def test_dispatch_happy_path(self): self.assertIsInstance(data, list) for row in data: self.assertIn("name", row) + self.assertIn("email", row) self.assertNotIn("phone", row) def test_dispatch_with_limit(self): @@ -201,9 +203,67 @@ def test_dispatch_default_domain_applied(self): res = self._call("contacts", "get_partners") self.assertEqual(res.status_code, 200) data = res.json() + self.assertTrue(data) names = [row["name"] for row in data] self.assertNotIn("Test Individual", names) + def test_dispatch_domain_merge(self): + company = self.env["res.partner"].create( + {"name": "MergeCo", "ref": "MERGE_TEST", "is_company": True} + ) + res = self._call( + "contacts", + "get_partners", + {"domain": [["ref", "=", "MERGE_TEST"]]}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["name"], company.name) + + def test_dispatch_dotted_fields(self): + country_jp = self.env["res.country"].search([("code", "=", "JP")], limit=1) + self.assertTrue(country_jp) + partner = self.env["res.partner"].create( + { + "name": "DottedCo", + "ref": "DOTTED_TEST", + "is_company": True, + "country_id": country_jp.id, + } + ) + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "dotted_partners", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + "json2_response_fields": "name\ncountry_id.name country", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "domain", + "param_type": "list", + } + ) + endpoint._handle_registry_sync() + res = self._call( + "test", "dotted_partners", {"domain": [["ref", "=", "DOTTED_TEST"]]} + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["name"], partner.name) + self.assertEqual(data[0]["country"], country_jp.name) + self.assertNotIn("country_id", data[0]) + self.assertNotIn("country_id.name", data[0]) + def test_dispatch_group_access_denied(self): group = self.env["res.groups"].create({"name": "Secret API Group"}) endpoint = self.env["endpoint.endpoint"].create( @@ -216,7 +276,7 @@ def test_dispatch_group_access_denied(self): "auth_type": "bearer", "json2_model_id": self.model_partner.id, "json2_method": "search_read", - "json2_group_ids": [(4, group.id)], + "json2_group_ids": [Command.link(group.id)], } ) endpoint._handle_registry_sync() @@ -312,6 +372,38 @@ def test_dispatch_code_snippet_missing_result(self): res = self._call("test", "bad_snippet") self.assertEqual(res.status_code, 500) + def test_dispatch_with_alias(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "aliased_partners", + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": self.model_partner.id, + "json2_method": "search_read", + "json2_response_fields": "name label\nemail", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "limit", + "param_type": "integer", + "default_value": "5", + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "aliased_partners") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertTrue(data) + for row in data: + self.assertIn("label", row) + self.assertNotIn("name", row) + self.assertIn("email", row) + def test_doc_excludes_restricted_endpoints(self): group = self.env["res.groups"].create({"name": "Hidden Group"}) self.env["endpoint.endpoint"].create( @@ -324,7 +416,7 @@ def test_doc_excludes_restricted_endpoints(self): "auth_type": "bearer", "json2_model_id": self.model_partner.id, "json2_method": "search_read", - "json2_group_ids": [(4, group.id)], + "json2_group_ids": [Command.link(group.id)], } ) res = self._call_doc() From d2772c75fa0ce92418c12a30dbcb5173d130c66f Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 24 May 2026 08:46:55 +0000 Subject: [PATCH 05/16] fixup! --- endpoint_json2/README.rst | 6 +- endpoint_json2/__manifest__.py | 2 +- endpoint_json2/models/endpoint_mixin.py | 37 ++++----- endpoint_json2/readme/CONFIGURE.md | 2 +- endpoint_json2/readme/DESCRIPTION.md | 2 +- endpoint_json2/static/description/index.html | 87 ++++++++++---------- 6 files changed, 63 insertions(+), 73 deletions(-) diff --git a/endpoint_json2/README.rst b/endpoint_json2/README.rst index 33334b3..f49aa1a 100644 --- a/endpoint_json2/README.rst +++ b/endpoint_json2/README.rst @@ -7,7 +7,7 @@ Endpoint JSON2 !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:5472d3ec0fc0bd5efbf0ba22d9a525da9789d0465823daa443bef74f3157193b + !! source digest: sha256:c7e6ba5b070db8b93a1ae44bd87344a39b885ff2b4667a1a26e9e7b1cc677402 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png @@ -29,7 +29,7 @@ Endpoint JSON2 |badge1| |badge2| |badge3| |badge4| |badge5| Adds ``exec_mode="json2"`` to the endpoint framework, enabling -declarative JSON2-RPC endpoint configuration. Instead of writing code +declarative JSON-2 API endpoint configuration. Instead of writing code snippets, select a model, method, and parameters — the module handles dispatch, parameter validation, access control, and result filtering. @@ -50,7 +50,7 @@ Configuration ============= Go to *Settings > Technical > Endpoints* and create a new endpoint with -**Exec Mode** set to **JSON2-RPC**. +**Exec Mode** set to **JSON-2 API**. Basic Setup ----------- diff --git a/endpoint_json2/__manifest__.py b/endpoint_json2/__manifest__.py index fb21597..cbcfcf6 100644 --- a/endpoint_json2/__manifest__.py +++ b/endpoint_json2/__manifest__.py @@ -2,7 +2,7 @@ # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). { "name": "Endpoint JSON2", - "summary": "Declarative JSON2-RPC endpoints on the endpoint stack", + "summary": "Declarative JSON-2 API endpoints on the endpoint stack", "version": "19.0.1.0.0", "license": "LGPL-3", "development_status": "Alpha", diff --git a/endpoint_json2/models/endpoint_mixin.py b/endpoint_json2/models/endpoint_mixin.py index 9132fb1..4d80146 100644 --- a/endpoint_json2/models/endpoint_mixin.py +++ b/endpoint_json2/models/endpoint_mixin.py @@ -78,7 +78,7 @@ class EndpointMixin(models.AbstractModel): ) def _selection_exec_mode(self): - return super()._selection_exec_mode() + [("json2", "JSON2-RPC")] + return super()._selection_exec_mode() + [("json2", "JSON-2 API")] @api.depends("route", "exec_mode", "route_group", "name") def _compute_route(self): @@ -100,12 +100,12 @@ def _onchange_exec_mode_json2_defaults(self): def _validate_exec__json2(self): if not self.json2_model_id: raise ValidationError( - self.env._("Exec mode is set to 'JSON2-RPC': you must select a model.") + self.env._("Exec mode is set to 'JSON-2 API': you must select a model.") ) if not self.json2_method and not self.json2_code_snippet: raise ValidationError( self.env._( - "Exec mode is set to 'JSON2-RPC': " + "Exec mode is set to 'JSON-2 API': " "you must specify a method or provide a code snippet." ) ) @@ -118,14 +118,14 @@ def _check_json2_request_settings(self): if rec.request_method != "POST": raise ValidationError( self.env._( - "JSON2-RPC endpoints must use POST " + "JSON-2 API endpoints must use POST " "(parameters are sent as a JSON body)." ) ) if rec.request_content_type != "application/json": raise ValidationError( self.env._( - "JSON2-RPC endpoints must use 'application/json' content type." + "JSON-2 API endpoints must use 'application/json' content type." ) ) @@ -274,8 +274,7 @@ def _json2_validate_params(self, kwargs): params[param_def.name] = value return params - @staticmethod - def _json2_check_param_type(value, expected_type): + def _json2_check_param_type(self, value, expected_type): if isinstance(value, bool) and expected_type is not bool: return False if expected_type is float: @@ -302,8 +301,7 @@ def _json2_parse_response_fields(self): aliases[parts[0]] = parts[1] return field_list, aliases - @staticmethod - def _json2_parse_dotted_fields(allowed): + def _json2_parse_dotted_fields(self, allowed): dotted = {} for f in allowed: if "." in f: @@ -358,8 +356,7 @@ def _json2_resolve_dotted_fields(self, env, result, dotted_map): row[f"{base_field}.{sub}"] = rec.get(sub, False) return result - @staticmethod - def _json2_extract_rel_ids(val): + def _json2_extract_rel_ids(self, val): if not val: return [] if isinstance(val, int): @@ -374,24 +371,21 @@ def _json2_extract_rel_ids(val): return [rec_id] if rec_id else [] return [] - @staticmethod - def _json2_serialize_value(val): + def _json2_serialize_value(self, val): if isinstance(val, datetime): return val.strftime("%Y-%m-%d %H:%M:%S") if isinstance(val, date): return val.isoformat() return val - @classmethod - def _json2_serialize_values(cls, result): + def _json2_serialize_values(self, result): if isinstance(result, list): - return [cls._json2_serialize_values(item) for item in result] + return [self._json2_serialize_values(item) for item in result] if isinstance(result, dict): - return {k: cls._json2_serialize_value(v) for k, v in result.items()} - return cls._json2_serialize_value(result) + return {k: self._json2_serialize_value(v) for k, v in result.items()} + return self._json2_serialize_value(result) - @staticmethod - def _json2_filter_result(result, response_fields): + def _json2_filter_result(self, result, response_fields): if not response_fields: return result if isinstance(result, list): @@ -405,8 +399,7 @@ def _json2_filter_result(result, response_fields): return {k: v for k, v in result.items() if k in response_fields} return result - @staticmethod - def _json2_apply_aliases(result, aliases): + def _json2_apply_aliases(self, result, aliases): def _rename(row): if not isinstance(row, dict): return row diff --git a/endpoint_json2/readme/CONFIGURE.md b/endpoint_json2/readme/CONFIGURE.md index 19a59c0..77238e3 100644 --- a/endpoint_json2/readme/CONFIGURE.md +++ b/endpoint_json2/readme/CONFIGURE.md @@ -1,5 +1,5 @@ Go to *Settings > Technical > Endpoints* and create a new endpoint with -**Exec Mode** set to **JSON2-RPC**. +**Exec Mode** set to **JSON-2 API**. ## Basic Setup diff --git a/endpoint_json2/readme/DESCRIPTION.md b/endpoint_json2/readme/DESCRIPTION.md index c3d0657..12fce6a 100644 --- a/endpoint_json2/readme/DESCRIPTION.md +++ b/endpoint_json2/readme/DESCRIPTION.md @@ -1,5 +1,5 @@ Adds `exec_mode="json2"` to the endpoint framework, enabling declarative -JSON2-RPC endpoint configuration. Instead of writing code snippets, select a +JSON-2 API endpoint configuration. Instead of writing code snippets, select a model, method, and parameters — the module handles dispatch, parameter validation, access control, and result filtering. diff --git a/endpoint_json2/static/description/index.html b/endpoint_json2/static/description/index.html index e295f8a..1acbb1c 100644 --- a/endpoint_json2/static/description/index.html +++ b/endpoint_json2/static/description/index.html @@ -1,21 +1,20 @@ - + - + Endpoint JSON2 -
-

Endpoint JSON2

+
+ + +Odoo Community Association + +
+

Endpoint JSON2

-

Alpha License: LGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

Alpha License: LGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

Adds exec_mode="json2" to the endpoint framework, enabling -declarative JSON-2 API endpoint configuration. Instead of writing code -snippets, select a model, method, and parameters — the module handles -dispatch, parameter validation, access control, and result filtering.

+declarative JSON-2 API endpoint configuration. Select a model, method, +and parameters — the module handles dispatch, parameter validation, +access control, and result filtering. A code snippet can be used as an +alternative to a model method for quick, ad-hoc logic.

Also provides auto-generated API documentation endpoints at /json2/doc.

@@ -384,34 +391,40 @@

Endpoint JSON2

Table of contents

-

Configuration

+

Configuration

Go to Settings > Technical > Endpoints and create a new endpoint with Exec Mode set to JSON-2 API.

-

Basic Setup

+

Basic Setup

    +
  • Route Group and Name: Together these determine the endpoint +URL, which is automatically computed as +/json2/{route_group}/{name}. For example, a route group +contacts with name get_partners produces +/json2/contacts/get_partners. The route group also organizes +endpoints in the API documentation at /json2/doc/{route_group}.

    +
  • Model: The Odoo model to operate on (e.g. res.partner).

  • Method: A public model method (e.g. search_read). @@ -419,9 +432,9 @@

    Basic Setup

    two fields are mutually exclusive.

  • Response Fields: One field per line. Optionally follow with an -alias to rename the key in the response. Use dotted notation for -relational fields (Many2one, Many2many, One2many). Leave empty to -return all fields. Example:

    +alias to rename the key in the response. Use dotted notation (one +level) for relational fields (Many2one, Many2many, One2many). Leave +empty to return all fields. Example:

     name
     email
    @@ -438,68 +451,60 @@ 

    Basic Setup

-

Access Control

+

Access Control

All endpoint execution is wrapped in sudo(), allowing API users to operate with minimal Odoo privileges. Access is controlled at two levels:

    -
  • Auth Type: Set to Bearer to require an API key for -authentication.
  • +
  • Auth Type: Select the authentication method for the endpoint +(e.g. Bearer for API key authentication).
  • Allowed Groups: Restrict endpoint access to specific user groups. Create integration-specific groups (e.g. “Hospital System”, “WMS”) and assign them to the corresponding API users. Each endpoint declares which groups may call it. Leave empty to allow any authenticated user.
  • -
  • Response Fields: Controls which data fields are included in the -response, regardless of what the underlying model method returns.
-

Code Snippets

-

For operations that go beyond a single model method call, use a code -snippet instead of the method field. Available variables:

+

Code Snippets

+

As an alternative to a model method, a code snippet can be used for +quick, ad-hoc logic. Available variables:

  • Model: The target model (with sudo()).
  • params: Validated parameters from the request.
  • env: The Odoo environment.
  • Command: Odoo’s Command helper for relational field writes.
  • +
  • json: Safe JSON module for serialization.
  • exceptions: Werkzeug exceptions (BadRequest, NotFound, etc.).
  • +
  • log: Log messages to the ir.logging table.

The snippet must set a result variable with the response data.

-

Usage

+

Usage

-

Calling an Endpoint

-

Send a POST request with a JSON body to the endpoint’s route:

+

Calling an Endpoint

+

Send a POST request with a JSON body to the endpoint’s route. The +example below uses Bearer authentication with an API key:

-curl -X POST https://your-odoo.com/json2/contacts/get_partners \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{"domain": [["is_company", "=", true]], "limit": 10}'
-
-
-
-

Incremental Sync

-

To fetch only records modified since a given timestamp, include a -write_date filter in the domain and add write_date to the -response fields:

-
-{"domain": [["write_date", ">=", "2026-05-23 00:00:00"]]}
+curl -X POST https://your-odoo.com/json2/contacts/get_partners \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{"domain": [["is_company", "=", true]], "limit": 10}'
 
-

Use the latest write_date from the response as the starting point -for the next sync to avoid clock drift between client and server.

-

API Documentation

-

Auto-generated documentation for all JSON2 endpoints is available at -/json2/doc.

+

API Documentation

+

Auto-generated documentation for all JSON-2 endpoints is available at +/json2/doc, grouped by route group. Each endpoint’s visibility +respects the Allowed Groups setting — users only see endpoints they +have access to. Filter by route group with /json2/doc/{route_group}.

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -507,15 +512,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Quartile
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

-Odoo Community Association + +Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

Current maintainers:

-

yostashiro aungkokolin1997

+

yostashiro aungkokolin1997

This module is part of the OCA/web-api project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
diff --git a/endpoint_json2/tests/test_endpoint_json2_controller.py b/endpoint_json2/tests/test_endpoint_json2_controller.py index 8d48b61..bc6fac5 100644 --- a/endpoint_json2/tests/test_endpoint_json2_controller.py +++ b/endpoint_json2/tests/test_endpoint_json2_controller.py @@ -13,14 +13,6 @@ CT_JSON = {"Content-Type": "application/json"} -# Intentionally not tagged @tagged("-at_install", "post_install"). -# When the post_install suite contains any HttpCase, Odoo pregenerates all -# asset bundles (see odoo/service/server.py:_pregenerate_assets_bundles), and -# compiling web.assets_frontend currently emits a WARNING for an undefined -# SCSS variable ($black) coming from Odoo core. The OCA CI counts that -# warning as a failure cause. Running this HttpCase at_install — matching the -# pattern in endpoint_auth_api_key.tests.test_endpoint_controller — avoids -# pulling pregeneration into the run. @skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") class TestEndpointJson2Controller(HttpCase): @classmethod From 1267505a518137dcce3d0e8d5d59e0b0a45f1eba Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 26 Jul 2026 08:47:49 +0000 Subject: [PATCH 12/16] [IMP] endpoint_json2: add response language forcing Translatable field values were returned in the API user's language, making integration payloads dependent on a user setting anyone can change. Add an optional Response Language (json2_lang_id) that forces the execution context language, so translated values (including dotted relational fields such as uom_id.name) are deterministic per endpoint. --- endpoint_json2/README.rst | 8 +- endpoint_json2/models/endpoint_endpoint.py | 9 +++ endpoint_json2/readme/CONFIGURE.md | 4 + endpoint_json2/static/description/index.html | 79 ++++++++++--------- .../tests/test_endpoint_json2_controller.py | 26 ++++++ endpoint_json2/views/endpoint_views.xml | 1 + 6 files changed, 87 insertions(+), 40 deletions(-) diff --git a/endpoint_json2/README.rst b/endpoint_json2/README.rst index f5f63eb..70b9df5 100644 --- a/endpoint_json2/README.rst +++ b/endpoint_json2/README.rst @@ -11,7 +11,7 @@ Endpoint JSON2 !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:c7e6ba5b070db8b93a1ae44bd87344a39b885ff2b4667a1a26e9e7b1cc677402 + !! source digest: sha256:fce5f9f2bfc7ac120cae3e1018e737759cfbd871cf932924ef102f7a8798e7cf !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png @@ -88,6 +88,12 @@ Basic Setup - **Default Domain**: A JSON-formatted domain filter applied to every request (e.g. ``[["active", "=", true]]``). +- **Response Language**: Optionally force a language on the execution + context so that translatable field values (including dotted + relational fields such as ``uom_id.name``) are returned in that + language regardless of the API user's language setting. Untranslated + values fall back to the source language. + - **Parameters**: Define named parameters with types, defaults, and required flags. These are validated before the method is called. diff --git a/endpoint_json2/models/endpoint_endpoint.py b/endpoint_json2/models/endpoint_endpoint.py index 311495a..96a3df7 100644 --- a/endpoint_json2/models/endpoint_endpoint.py +++ b/endpoint_json2/models/endpoint_endpoint.py @@ -53,6 +53,13 @@ class EndpointEndpoint(models.Model): default="[]", help="Default domain filter applied before calling the method (JSON format).", ) + json2_lang_id = fields.Many2one( + "res.lang", + string="Response Language", + help="Force this language on the execution context so that translatable " + "field values (including dotted relational fields) are returned in it, " + "regardless of the API user's language.", + ) json2_group_ids = fields.Many2many( "res.groups", string="Allowed Groups", @@ -385,6 +392,8 @@ def _handle_exec__json2(self, request): kwargs = request.get_json_data() or {} params = self._json2_validate_params(kwargs) Model = request.env[self.json2_model_name].sudo() + if self.json2_lang_id: + Model = Model.with_context(lang=self.json2_lang_id.code) default_domain = json.loads(self.json2_default_domain or "[]") if default_domain: params["domain"] = default_domain + (params.get("domain") or []) diff --git a/endpoint_json2/readme/CONFIGURE.md b/endpoint_json2/readme/CONFIGURE.md index a7ad538..47d0c36 100644 --- a/endpoint_json2/readme/CONFIGURE.md +++ b/endpoint_json2/readme/CONFIGURE.md @@ -23,6 +23,10 @@ set to **JSON-2 API**. ``` - **Default Domain**: A JSON-formatted domain filter applied to every request (e.g. `[["active", "=", true]]`). +- **Response Language**: Optionally force a language on the execution context so + that translatable field values (including dotted relational fields such as + `uom_id.name`) are returned in that language regardless of the API user's + language setting. Untranslated values fall back to the source language. - **Parameters**: Define named parameters with types, defaults, and required flags. These are validated before the method is called. diff --git a/endpoint_json2/static/description/index.html b/endpoint_json2/static/description/index.html index 42c204b..dada1ae 100644 --- a/endpoint_json2/static/description/index.html +++ b/endpoint_json2/static/description/index.html @@ -2,19 +2,18 @@ - + README.rst