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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/senaite/jsonapi/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 23 additions & 3 deletions src/senaite/jsonapi/api/mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"""

import copy
import difflib

import transaction
from AccessControl import Unauthorized
Expand All @@ -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
Expand Down Expand Up @@ -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`.

Expand All @@ -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():
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/senaite/jsonapi/tests/doctests/uidreferences.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
Loading