From b92970189d363bcc3f2354fd75a2637295bd6425 Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 9 Aug 2026 11:19:48 +0000 Subject: [PATCH] [IMP] endpoint_json2: let a method declare its own response fields Response fields were validated against the target model's fields for every endpoint. That is right when the method is read or search_read, whose result rows are made of model fields, and wrong for any other method: its keys are its own and the model has no way to know them. A method returning a derived value therefore could not name it here at all, and had to leave the field empty -- which turns off filtering and aliasing entirely and leaves the payload implicit in the code, invisible to anyone reading the endpoint record. Plain names are now checked only for read and search_read. Dotted specs stay checked whatever the method is, because _json2_resolve_dotted_fields resolves their base against the model's fields before fetching the related rows, so a base that is not a relational field can never resolve. The constraint now also depends on the method, so that switching an endpoint back to search_read re-runs the check rather than leaving keys behind that the model does not have. Assisted-by: Claude Opus 5 --- endpoint_json2/models/endpoint_endpoint.py | 16 ++++++- endpoint_json2/readme/CONFIGURE.md | 6 +++ endpoint_json2/tests/test_endpoint_json2.py | 46 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/endpoint_json2/models/endpoint_endpoint.py b/endpoint_json2/models/endpoint_endpoint.py index 6f14f73..8e4b997 100644 --- a/endpoint_json2/models/endpoint_endpoint.py +++ b/endpoint_json2/models/endpoint_endpoint.py @@ -200,7 +200,7 @@ def _json2_is_valid_response_field(self, Model, field_spec): and sub in self.env[fd.comodel_name]._fields ) - @api.constrains("json2_response_fields", "json2_model_id") + @api.constrains("json2_response_fields", "json2_model_id", "json2_method") def _check_json2_response_fields(self): for rec in self: if not rec.json2_response_fields or not rec.json2_model_name: @@ -209,10 +209,22 @@ def _check_json2_response_fields(self): continue Model = self.env[rec.json2_model_name] field_names, _aliases = rec._json2_parse_response_fields() + # Plain names are checked only for these two methods. This is a + # policy choice, not a taxonomy of the ORM: read_group returns model + # field values too and is deliberately excluded, because its rows + # carry keys of its own (__count, __domain) as well. A module wanting + # its own method checked should constrain it where the payload is + # defined, which can pin the exact keys rather than merely "is a + # field of the model". + # Dotted specs stay checkable whatever the method is, because + # _json2_resolve_dotted_fields resolves their base against + # Model._fields before injecting the related values. + reads_fields = rec.json2_method in ("read", "search_read") invalid = [ f for f in field_names - if not rec._json2_is_valid_response_field(Model, f) + if ("." in f or reads_fields) + and not rec._json2_is_valid_response_field(Model, f) ] if invalid: raise ValidationError( diff --git a/endpoint_json2/readme/CONFIGURE.md b/endpoint_json2/readme/CONFIGURE.md index 792da91..86c5210 100644 --- a/endpoint_json2/readme/CONFIGURE.md +++ b/endpoint_json2/readme/CONFIGURE.md @@ -21,6 +21,12 @@ set to **JSON-2 API**. country_id.name country write_date last_modified ``` + + Works with any method whose rows are objects; rows of another shape (ids, + `(id, name)` pairs, a scalar) pass through untouched. Plain names are validated + against the model only for `read` and `search_read` — for anything else the keys + are the method's own, so a typo is not reported and simply drops that key from + the response. Dotted specs are always validated. - **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 diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py index 1b028ed..edd0a8a 100644 --- a/endpoint_json2/tests/test_endpoint_json2.py +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -35,6 +35,52 @@ def test_invalid_response_fields(self): with self.assertRaises(ValidationError): self.endpoint.json2_response_fields = "name\ncountry_id.nonexistent" + def test_plain_names_unvalidated_when_not_a_field_reader(self): + """Neither is read/search_read, so their declared names go unchecked. + + The snippet body is never run by the constraint; it is only there + because an endpoint needs either a method or a snippet. + """ + for name, response_fields, vals in ( + ("counts", "__count total", {"json2_method": "read_group"}), + ("summary", "branch_code", {"json2_code_snippet": "result = []"}), + ): + endpoint = self._create_endpoint( + dict(vals, name=f"get_{name}", json2_response_fields=response_fields) + ) + self.assertEqual(endpoint.json2_response_fields, response_fields) + + def test_dotted_specs_validated_whatever_the_method(self): + """Their base must resolve against the model, method or not.""" + endpoint = self._create_endpoint( + { + "name": "get_counts_dotted", + "json2_method": "read_group", + "json2_response_fields": "__count\ncountry_id.name country", + } + ) + bad = ("nonexistent_id.name", "email.something", "country_id.nonexistent") + for spec in bad: + with self.assertRaises(ValidationError): + endpoint.json2_response_fields = f"__count\n{spec}" + + def test_plain_names_still_validated_for_search_read(self): + """__count is not a field of res.partner.""" + with self.assertRaises(ValidationError): + self.endpoint.json2_response_fields = "name\n__count" + + def test_changing_method_rechecks_response_fields(self): + """The constraint depends on json2_method, so a switch must re-run it.""" + endpoint = self._create_endpoint( + { + "name": "get_switched", + "json2_method": "read_group", + "json2_response_fields": "country_id\n__count", + } + ) + with self.assertRaises(ValidationError): + endpoint.json2_method = "search_read" + def test_empty_response_fields(self): self.endpoint.json2_response_fields = False fields, aliases = self.endpoint._json2_parse_response_fields()