From ab7a434d9eec02fe97b0621c1df2954dabc7d848 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Wed, 7 Oct 2026 08:54:20 +0200 Subject: [PATCH] Refuse a field name that is not a field A name in a record that matched no field and no setter was dropped with a line in the log, and the request answered that the object had been created or updated. A caller asking for `Service` instead of `services` was told that all was well and got back a profile with nothing in it, which is how an afternoon goes missing. It is refused now, and the message names the field the caller probably meant: No field named 'Service' on AnalysisProfile. Did you mean 'services'? The keys that address the object or steer the request rather than naming a field (portal_type, parent_path, parent_uid, path, uid, id, transition) are listed as such and skipped, as `id` already was. This builds on #115. A name a Dexterity type kept from its Archetypes days has to reach its field first; without that, every payload written against the documented API, which uses those names throughout, would now be refused. --- docs/changelog.rst | 1 + src/senaite/jsonapi/api/__init__.py | 5 ++++ src/senaite/jsonapi/api/mutation.py | 26 +++++++++++++++--- .../jsonapi/tests/doctests/uidreferences.rst | 27 +++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab864b5..24dbe4f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,7 @@ Changelog applied. Uninstalling from the same panel removes the PAS plugin and the per-user JWT signing secrets. +- #116 Refuse a field name that is not a field - #115 Fix single valued Dexterity UID references not settable - #109 Add a partition operation endpoint - #106 Fix single-valued UID reference fields not settable through the JSON API diff --git a/src/senaite/jsonapi/api/__init__.py b/src/senaite/jsonapi/api/__init__.py index 3561a0a..34d1984 100644 --- a/src/senaite/jsonapi/api/__init__.py +++ b/src/senaite/jsonapi/api/__init__.py @@ -48,6 +48,11 @@ SKIP_UPDATE_FIELDS = ["id", ] +# Keys that address the object or steer the request rather than naming +# one of its fields. Everything else in a record is taken for a field. +CONTROL_FIELDS = ("id", "parent_path", "parent_uid", "path", + "portal_type", "transition", "uid") + # ----------------------------------------------------------------------------- # JSON API (CRUD) Functions (called by the route providers) diff --git a/src/senaite/jsonapi/api/mutation.py b/src/senaite/jsonapi/api/mutation.py index 86d55d8..33c0604 100644 --- a/src/senaite/jsonapi/api/mutation.py +++ b/src/senaite/jsonapi/api/mutation.py @@ -33,6 +33,7 @@ """ import copy +import difflib import transaction from AccessControl import Unauthorized @@ -48,12 +49,14 @@ from senaite.jsonapi.api import convert_physical_paths_to_objects from senaite.jsonapi.api import do_transition_for from senaite.jsonapi.api import find_objects +from senaite.jsonapi.api import get_fields from senaite.jsonapi.api import get_object from senaite.jsonapi.api import get_object_by_path from senaite.jsonapi.api import get_object_by_record from senaite.jsonapi.api import get_object_by_uid from senaite.jsonapi.api import is_root from senaite.jsonapi.api import make_items_for +from senaite.jsonapi.api import CONTROL_FIELDS from senaite.jsonapi.api import SKIP_UPDATE_FIELDS from senaite.jsonapi.exceptions import BadRequestError from senaite.jsonapi.exceptions import ForbiddenError @@ -290,6 +293,24 @@ def create_analysisrequest(container, **data): return _create_ar(container, request, data) +def no_such_field(content, name): + """Say that a field is not there, and which one was probably meant + + A name that matches no field and no setter used to be dropped with + a line in the log, and the request answered that the object had + been created or updated. A caller asking for `Service` instead of + `services` was told that all was well and got back an object with + nothing in it. + """ + names = sorted(get_fields(content)) + close = difflib.get_close_matches(name, names, n=1, cutoff=0.6) + message = "No field named '%s' on %s" % ( + name, bika_api.get_portal_type(content)) + if close: + message = "%s. Did you mean '%s'?" % (message, close[0]) + return message + + def update_object_with_data(content, record): """Update `content` with the fields from `record`. @@ -310,7 +331,7 @@ def update_object_with_data(content, record): raise BadRequestError("Update for this object is not allowed") purged = copy.deepcopy(record) - for key in SKIP_UPDATE_FIELDS: + for key in set(SKIP_UPDATE_FIELDS) | set(CONTROL_FIELDS): purged.pop(key, None) for k, v in purged.items(): @@ -322,8 +343,7 @@ def update_object_with_data(content, record): raise BadRequestError(str(exc)) if success is False: - logger.warning("update_object_with_data::skipping key=%r", k) - continue + raise BadRequestError(no_such_field(content, k)) logger.debug("update_object_with_data::field %r updated", k) # Validate the whole object only for the field-manager path, where diff --git a/src/senaite/jsonapi/tests/doctests/uidreferences.rst b/src/senaite/jsonapi/tests/doctests/uidreferences.rst index 715db4e..df92d09 100644 --- a/src/senaite/jsonapi/tests/doctests/uidreferences.rst +++ b/src/senaite/jsonapi/tests/doctests/uidreferences.rst @@ -150,6 +150,33 @@ More than one is refused, because the field holds a single reference: HTTPError: HTTP Error 400: Bad Request +A name that is no field at all +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A BBB name is a name the type supports, and it reaches the field. A +name that reaches nothing is a mistake, and saying so is the whole +point of the two being told apart: + + >>> browser.raiseHttpErrors = False + >>> print(post("create", { + ... "portal_type": "AnalysisProfile", + ... "parent_path": api.get_path(setup.analysisprofiles), + ... "title": "Typo Panel", + ... "Service": []})) + {...No field named 'Service' on AnalysisProfile. Did you mean 'services'?...} + +Without a field close enough to suggest, it says only what it knows: + + >>> print(post("create", { + ... "portal_type": "AnalysisProfile", + ... "parent_path": api.get_path(setup.analysisprofiles), + ... "title": "Typo Panel", + ... "Nonsense": 42})) + {...No field named 'Nonsense' on AnalysisProfile...} + + >>> browser.raiseHttpErrors = True + + A single valued Archetypes reference ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~