From 8b53ce38a554d7148fb8a80a3d2c34a5e9e0650d Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Tue, 6 Oct 2026 13:22:05 +0200 Subject: [PATCH 1/3] Validate only the submitted fields on update update_object_with_data re-validated the whole object after applying the record. The field managers already validate each submitted field on set, so that second pass only re-checks untouched fields, and fails when one of them no longer passes a full validation on its own: an Archetypes field holding a stored legacy value, or a Dexterity behavior invariant such as IExcludeFromNavigationDefault. A partial update of a setup object was refused even when the submitted field itself was fine. Creation still validates the whole object, which is what #66 is about. Rewritten against the current 2.x: the route helpers moved to api/mutation in #97 after this change was first written. --- docs/changelog.rst | 1 + src/senaite/jsonapi/api/mutation.py | 25 ++++++++++--- src/senaite/jsonapi/tests/doctests/update.rst | 37 +++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3478b8d..ebb3e4d 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. +- #107 Validate only the submitted fields on update - #109 Add a partition operation endpoint - #106 Fix single-valued UID reference fields not settable through the JSON API - #112 Allow updating the Laboratory through the API diff --git a/src/senaite/jsonapi/api/mutation.py b/src/senaite/jsonapi/api/mutation.py index 86d55d8..96a78a5 100644 --- a/src/senaite/jsonapi/api/mutation.py +++ b/src/senaite/jsonapi/api/mutation.py @@ -158,7 +158,7 @@ def update_items(portal_type=None, uid=None, endpoint=None, **kw): if not is_update_allowed(obj): raise ForbiddenError( "Update of {} is not allowed".format(bika_api.get_path(obj))) - obj = update_object_with_data(obj, record) + obj = update_object_with_data(obj, record, partial=True) return make_items_for([obj], endpoint=endpoint) results = [] @@ -169,7 +169,7 @@ def update_items(portal_type=None, uid=None, endpoint=None, **kw): if not is_update_allowed(obj): raise ForbiddenError( "Update of {} is not allowed".format(bika_api.get_path(obj))) - obj = update_object_with_data(obj, record) + obj = update_object_with_data(obj, record, partial=True) results.append(obj) if not results: @@ -290,11 +290,15 @@ def create_analysisrequest(container, **data): return _create_ar(container, request, data) -def update_object_with_data(content, record): +def update_object_with_data(content, record, partial=False): """Update `content` with the fields from `record`. Delegates to a registered `IUpdate` adapter if one exists; otherwise uses the default `IDataManager` machinery. + + :param partial: True on the update route, where only the submitted + fields are checked. False on creation, where the whole object is + validated. """ content = get_object(content) @@ -332,9 +336,18 @@ def update_object_with_data(content, record): # worksheet's addAnalyses populates system-managed fields such as # the layout that the generic schema validator cannot check) and # is responsible for its own validity. - invalid = bika_api.validate(content) - if invalid: - raise BadRequestError(u.to_json(invalid)) + # + # And only on creation. The field managers have already validated + # each submitted field on set, so a second pass over the whole + # object re-checks fields nobody touched, and fails on one that no + # longer passes a full validation on its own: an Archetypes field + # holding a stored legacy value, or a Dexterity behavior invariant + # such as IExcludeFromNavigationDefault. That used to refuse a + # partial update of a setup object whose submitted field was fine. + if not partial: + invalid = bika_api.validate(content) + if invalid: + raise BadRequestError(u.to_json(invalid)) if record.get("transition", None): t = record.get("transition") diff --git a/src/senaite/jsonapi/tests/doctests/update.rst b/src/senaite/jsonapi/tests/doctests/update.rst index b12a332..f55fc77 100644 --- a/src/senaite/jsonapi/tests/doctests/update.rst +++ b/src/senaite/jsonapi/tests/doctests/update.rst @@ -192,3 +192,40 @@ We cannot update the `id` of an object: >>> obj = get_item_object(response) >>> api.get_id(obj) == original_id True + + +Partial validation on update +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An update only validates the fields that were submitted. Fields left untouched +are not re-validated, so a pre-existing problem on another field does not block +an unrelated update. + + >>> data = {"portal_type": "Client", + ... "parent_path": api.get_path(clients), + ... "title": "Dolphin Corp", + ... "ClientID": "DC"} + >>> client4 = create(data) + +Clear its required `ClientID` field, bypassing the API so the stored object no +longer passes a full validation: + + >>> client4.setClientID("") + >>> transaction.commit() + +Updating a different field still succeeds (the empty `ClientID` is not +re-validated): + + >>> data = {"uid": api.get_uid(client4), "title": "Dolphin Ltd"} + >>> response = post("update", data) + >>> obj = get_item_object(response) + >>> obj.Title() + 'Dolphin Ltd' + +But submitting the offending field itself is still validated and rejected: + + >>> data = {"uid": api.get_uid(client4), "ClientID": ""} + >>> post("update", data) + Traceback (most recent call last): + [...] + HTTPError: HTTP Error 400: Bad Request From d492ff988c24446dca1388e8abe94842f82b57f0 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Tue, 28 Jul 2026 20:44:32 +0200 Subject: [PATCH 2/3] Add introspection endpoints for types, schema and workflow Adds three read-only routes so a client can discover the data model instead of scraping the UI: /types list the portal types (optionally addable in a container via parent_uid / parent_path) /types/ describe a single portal type, including the catalogs that index it and the permission required to add it /schema/ describe the schema fields (type, required, readonly, and reference target types), for both Archetypes and Dexterity content /workflow/ the current review state and the available transitions --- .../jsonapi/tests/doctests/introspect.rst | 132 ++++++++++ src/senaite/jsonapi/v1/routes/introspect.py | 228 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 src/senaite/jsonapi/tests/doctests/introspect.rst create mode 100644 src/senaite/jsonapi/v1/routes/introspect.py diff --git a/src/senaite/jsonapi/tests/doctests/introspect.rst b/src/senaite/jsonapi/tests/doctests/introspect.rst new file mode 100644 index 0000000..0efb3f0 --- /dev/null +++ b/src/senaite/jsonapi/tests/doctests/introspect.rst @@ -0,0 +1,132 @@ +INTROSPECT +---------- + +Introspection endpoints let a client discover the portal types, their schema +fields and the available workflow transitions without scraping the UI. + +Running this test from the buildout directory: + + bin/test test_doctests -t introspect + + +Test Setup +~~~~~~~~~~ + +Needed Imports: + + >>> import json + >>> import transaction + >>> import urllib + >>> from plone.app.testing import setRoles + >>> from plone.app.testing import TEST_USER_ID + + >>> from bika.lims import api + +Functional Helpers: + + >>> def get(url): + ... browser.open("{}/{}".format(api_url, url)) + ... return browser.contents + + >>> def post(url, data): + ... url = "{}/{}".format(api_url, url) + ... browser.post(url, urllib.urlencode(data, doseq=True)) + ... return browser.contents + + >>> def create(data): + ... response = post("create", data) + ... items = json.loads(response).get("items") + ... return api.get_object(items[0]["uid"]) + +Variables: + + >>> portal = self.portal + >>> portal_url = portal.absolute_url() + >>> api_url = "{}/@@API/senaite/v1".format(portal_url) + >>> browser = self.getBrowser() + >>> setRoles(portal, TEST_USER_ID, ["LabManager", "Manager"]) + >>> transaction.commit() + +Create a client to introspect: + + >>> clients = api.get_portal().clients + >>> data = {"portal_type": "Client", + ... "parent_path": api.get_path(clients), + ... "title": "Chicken Corp", + ... "ClientID": "CC"} + >>> client = create(data) + + +Types +~~~~~ + +List the registered portal types: + + >>> response = get("types") + >>> data = json.loads(response) + >>> "Client" in [t["portal_type"] for t in data["items"]] + True + +Describe a single portal type. The detail includes the catalogs that index +the type and the permission required to add it: + + >>> response = get("types/Client") + >>> data = json.loads(response) + >>> data["portal_type"] + u'Client' + + >>> isinstance(data["catalogs"], list) and len(data["catalogs"]) > 0 + True + + >>> "add_permission" in data + True + +An unknown portal type returns a 404: + + >>> get("types/NoSuchType") + Traceback (most recent call last): + [...] + HTTPError: HTTP Error 404: Not Found + + +Schema +~~~~~~ + +Describe the schema fields of a portal type: + + >>> response = get("schema/Client") + >>> data = json.loads(response) + >>> data["portal_type"] + u'Client' + + >>> field_names = [f["name"] for f in data["fields"]] + >>> "ClientID" in field_names + True + +The `ClientID` field is required: + + >>> client_id = [f for f in data["fields"] if f["name"] == "ClientID"][0] + >>> client_id["required"] + True + + +Workflow +~~~~~~~~ + +Return the current state and the available transitions of an object: + + >>> uid = api.get_uid(client) + >>> response = get("workflow/{}".format(uid)) + >>> data = json.loads(response) + >>> data["review_state"] + u'active' + + >>> "deactivate" in [t["id"] for t in data["transitions"]] + True + +An unknown uid returns a 404: + + >>> get("workflow/no-such-uid") + Traceback (most recent call last): + [...] + HTTPError: HTTP Error 404: Not Found diff --git a/src/senaite/jsonapi/v1/routes/introspect.py b/src/senaite/jsonapi/v1/routes/introspect.py new file mode 100644 index 0000000..188d433 --- /dev/null +++ b/src/senaite/jsonapi/v1/routes/introspect.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.JSONAPI. +# +# SENAITE.JSONAPI is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, version 2. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2017-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +from bika.lims import api +from senaite.jsonapi.api import fail +from senaite.jsonapi.v1 import add_route + + +def get_field_type(field): + """Return a readable type name for an AT or DX schema field + + :param field: An Archetypes field or a zope.schema field + :returns: The field type name + :rtype: str + """ + # Archetypes fields carry a `type` string, e.g. `string`, `reference` + at_type = getattr(field, "type", None) + if isinstance(at_type, str): + return at_type + # Dexterity (zope.schema) fields: use the class name, e.g. `TextLine` + return field.__class__.__name__ + + +def is_field_readonly(field): + """Check whether a field cannot be written through the API + + :param field: An Archetypes field or a zope.schema field + :rtype: bool + """ + # Archetypes uses a mode string (`rw`, `r`, `w`) + mode = getattr(field, "mode", None) + if isinstance(mode, str): + return "w" not in mode + # Dexterity fields expose a readonly flag + return bool(getattr(field, "readonly", False)) + + +def get_field_info(name, field): + """Serialize a single schema field to a JSON compatible mapping + + :param name: The field name + :param field: An Archetypes field or a zope.schema field + :rtype: dict + """ + info = { + "name": name, + "type": get_field_type(field), + "required": bool(getattr(field, "required", False)), + "readonly": is_field_readonly(field), + } + + # Reference fields expose the portal types they may point to + allowed_types = getattr(field, "allowed_types", None) + if allowed_types: + info["allowed_types"] = list(allowed_types) + + # Single vs. multi valued (AT uses `multiValued`, DX `multi_valued`) + multi_valued = getattr(field, "multiValued", None) + if multi_valued is None: + multi_valued = getattr(field, "multi_valued", None) + if multi_valued is not None: + info["multi_valued"] = bool(multi_valued) + + return info + + +def get_type_info(fti): + """Serialize a portal type (FTI) to a JSON compatible mapping + + :param fti: A portal type factory type information object + :rtype: dict + """ + return { + "portal_type": fti.getId(), + "title": fti.Title(), + "description": fti.Description(), + "meta_type": getattr(fti, "content_meta_type", None), + "global_allow": bool(getattr(fti, "global_allow", False)), + } + + +def get_catalog_ids(portal_type): + """Return the ids of the catalogs that index the given portal type + + :param portal_type: The portal type name + :rtype: list + """ + catalogs = api.get_catalogs_for(str(portal_type)) + return sorted([catalog.getId() for catalog in catalogs]) + + +def get_type_detail(fti): + """Serialize a portal type with the extra detail needed to decide where + and how to create it: the permission required to add it and the catalogs + that index it. `global_allow` tells whether the type is addable anywhere + or only where a container explicitly allows it, in which case the addable + types of a given container are available via `/types?parent_uid=`. + + :param fti: A portal type factory type information object + :rtype: dict + """ + info = get_type_info(fti) + info["add_permission"] = getattr(fti, "add_permission", None) + info["catalogs"] = get_catalog_ids(fti.getId()) + return info + + +def get_fields_for(portal_type): + """Return the name to field mapping for a portal type + + Dexterity types (including their behaviors) are resolved directly from + the type name. Archetypes types have no per-type schema lookup, so the + schema is taken from an existing instance when one is available. + + :param portal_type: The portal type name + :rtype: OrderedDict + """ + # component lookups (IDexterityFTI) require a native str name, but URL + # traversal yields unicode + portal_type = str(portal_type) + # Archetypes types have no per-type schema lookup, so introspect an + # existing instance when one is available + if api.is_at_type(portal_type): + results = api.search({"portal_type": portal_type}) + if results: + return api.get_fields(results[0]) + return {} + # Dexterity types (including their behaviors) resolve from the type name + return api.get_fields(portal_type) + + +@add_route("/types", "senaite.jsonapi.v1.types", methods=["GET"]) +@add_route("/types/", + "senaite.jsonapi.v1.types", methods=["GET"]) +def types(context, request, portal_type=None): + """List the registered portal types, or describe a single one + + Pass `parent_uid` (or `parent_path`) to restrict the listing to the + types that can be added inside that container. + """ + portal_types = api.get_tool("portal_types") + + if portal_type: + fti = portal_types.getTypeInfo(portal_type) + if fti is None: + fail(404, "Unknown portal type '{}'".format(portal_type)) + return get_type_detail(fti) + + # optionally restrict to the types addable in a container + container = None + parent_uid = request.get("parent_uid") + parent_path = request.get("parent_path") + if parent_uid: + container = api.get_object_by_uid(parent_uid, None) + elif parent_path: + container = api.get_object_by_path(parent_path, None) + + if container is not None: + ftis = container.allowedContentTypes() + else: + ftis = portal_types.listTypeInfo() + + items = sorted([get_type_info(t) for t in ftis], + key=lambda info: info["portal_type"]) + return {"count": len(items), "items": items} + + +@add_route("/schema/", + "senaite.jsonapi.v1.schema", methods=["GET"]) +def schema(context, request, portal_type=None): + """Describe the schema fields of a portal type + + Returns each field with its type, whether it is required or read only, + and, for reference fields, the portal types it may point to. + """ + portal_types = api.get_tool("portal_types") + if portal_types.getTypeInfo(portal_type) is None: + fail(404, "Unknown portal type '{}'".format(portal_type)) + + fields = get_fields_for(portal_type) + items = [get_field_info(name, field) for name, field in fields.items()] + return { + "portal_type": portal_type, + "count": len(items), + "fields": items, + } + + +@add_route("/workflow/", + "senaite.jsonapi.v1.workflow", methods=["GET"]) +def workflow(context, request, uid=None): + """Return the current workflow state and the available transitions + + This answers "what can I do with this object right now" without having + to guess transition ids. + """ + obj = api.get_object_by_uid(uid, None) + if obj is None: + fail(404, "No object found for uid '{}'".format(uid)) + + wftool = api.get_tool("portal_workflow") + transitions = [ + {"id": transition["id"], "title": transition["title"]} + for transition in wftool.getTransitionsFor(obj) + ] + return { + "uid": uid, + "portal_type": api.get_portal_type(obj), + "review_state": api.get_review_status(obj), + "transitions": transitions, + } From f9a07b2b98e2d0fc798d0406aa99353760e2c51f Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Tue, 28 Jul 2026 20:44:32 +0200 Subject: [PATCH 3/3] Add changelog entry for #108 --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ebb3e4d..622ddf3 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. +- #108 Add introspection endpoints for types, schema and workflow - #107 Validate only the submitted fields on update - #109 Add a partition operation endpoint - #106 Fix single-valued UID reference fields not settable through the JSON API