From 1920869ae50b58127d4dc4b67d3d6f08dd465fc7 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Fri, 13 Mar 2026 22:54:25 +0100 Subject: [PATCH 1/3] Allow to select fields --- src/senaite/jsonapi/api.py | 6 ++++++ src/senaite/jsonapi/request.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/senaite/jsonapi/api.py b/src/senaite/jsonapi/api.py index c911618..4a61208 100644 --- a/src/senaite/jsonapi/api.py +++ b/src/senaite/jsonapi/api.py @@ -298,10 +298,16 @@ def make_items_for(brains_or_objects, endpoint=None, complete=False): # check if the user wants to include children include_children = req.get_children(False) + # optional field projection: ?fields=uid,id,title,... + # empty set means no projection (full object returned) + fields = req.get_fields() + def extract_data(brain_or_object): info = get_info(brain_or_object, endpoint=endpoint, complete=complete) if include_children and is_folderish(brain_or_object): info.update(get_children_info(brain_or_object, complete=complete)) + if fields: + info = {k: info[k] for k in fields if k in info} return info return map(extract_data, brains_or_objects) diff --git a/src/senaite/jsonapi/request.py b/src/senaite/jsonapi/request.py index a73acef..78551bd 100644 --- a/src/senaite/jsonapi/request.py +++ b/src/senaite/jsonapi/request.py @@ -228,6 +228,20 @@ def get_uids(): return [v.strip() for v in value.split(",") if v.strip()] +def get_fields(): + """Returns the set of field names requested via the 'fields' parameter. + + Accepts a comma-separated string, e.g. ``?fields=uid,id,title``. + Returns an empty set when the parameter is absent or blank. + An empty set means no projection is applied and the full object is + returned (unchanged behaviour). + """ + value = get("fields", "") + if not value: + return set() + return set(v.strip() for v in value.split(",") if v.strip()) + + def get_request_data(): """ extract and convert the json data from the request From fef46b779c8947e026f655b72dc117ad640c0272 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Fri, 13 Mar 2026 22:59:09 +0100 Subject: [PATCH 2/3] Added doctest --- .../tests/doctests/field_projection.rst | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/senaite/jsonapi/tests/doctests/field_projection.rst diff --git a/src/senaite/jsonapi/tests/doctests/field_projection.rst b/src/senaite/jsonapi/tests/doctests/field_projection.rst new file mode 100644 index 0000000..32a83f5 --- /dev/null +++ b/src/senaite/jsonapi/tests/doctests/field_projection.rst @@ -0,0 +1,188 @@ +FIELD PROJECTION +---------------- + +Running this test from the buildout directory: + + bin/test test_doctests -t field_projection + + +Test Setup +~~~~~~~~~~ + +Needed Imports: + + >>> import json + >>> import transaction + >>> 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 get_count(response): + ... data = json.loads(response) + ... return data.get("count") + + >>> def get_item_keys(response, index=0): + ... data = json.loads(response) + ... items = data.get("items", []) + ... if not items: + ... return [] + ... return sorted(items[index].keys()) + +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"]) + +Create two Client objects and commit so they are indexed: + + >>> c1 = api.create(portal.clients, "Client", title="Alpha Lab", ClientID="AL") + >>> c2 = api.create(portal.clients, "Client", title="Beta Lab", ClientID="BL") + >>> uid1 = api.get_uid(c1) + >>> uid2 = api.get_uid(c2) + >>> transaction.commit() + + +Basic projection — only requested fields are returned +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requesting ``uid,id,title`` returns exactly those three keys in each item: + + >>> response = get("client?fields=uid,id,title") + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "id" in keys + True + >>> "title" in keys + True + +Fields that were not requested are absent: + + >>> "portal_type" in keys + False + >>> "url" in keys + False + >>> "path" in keys + False + + +Projection with complete=1 +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Field projection applies after object wake-up, so ``complete=1`` fields +are also subject to it: + + >>> response = get("client?complete=1&fields=uid,title,ClientID") + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "title" in keys + True + >>> "ClientID" in keys + True + +Fields outside the requested set are still excluded: + + >>> "id" in keys + False + >>> "portal_type" in keys + False + + +Requested content is correct +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The projected values match the objects that were created: + + >>> response = get("client?fields=uid,title") + >>> data = json.loads(response) + >>> titles = sorted(item["title"] for item in data["items"]) + >>> titles + [u'Alpha Lab', u'Beta Lab'] + + +Projection with a single requested field +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requesting only ``title`` returns items with exactly one key: + + >>> response = get("client?fields=title") + >>> keys = get_item_keys(response) + >>> keys + [u'title'] + + +Unknown fields are silently omitted +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A field name that does not exist on the object is ignored rather than +raising an error: + + >>> response = get("client?fields=uid,nonexistent_field") + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "nonexistent_field" in keys + False + + +No projection when fields parameter is absent +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Without a ``fields`` parameter the full object is returned, including +the standard set of metadata fields: + + >>> response = get("client?uids={}".format(uid1)) + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "url" in keys + True + >>> "portal_type" in keys + True + + +Projection combined with batch UID fetch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``?fields=`` and ``?uids=`` can be combined in a single request: + + >>> response = get("client?uids={},{}&fields=uid,title".format(uid1, uid2)) + >>> get_count(response) + 2 + >>> data = json.loads(response) + >>> keys = sorted(data["items"][0].keys()) + >>> keys + [u'title', u'uid'] + >>> "Alpha Lab" in response + True + >>> "Beta Lab" in response + True + + +Projection on the generic /search route +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``fields`` parameter works on the ``/search`` endpoint as well: + + >>> response = get( + ... "search?portal_type=Client&fields=uid,id,title" + ... ) + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "id" in keys + True + >>> "title" in keys + True + >>> "portal_type" in keys + False From 81b640c8169b669848c4aaea9867e973f7931e5c Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Fri, 13 Mar 2026 23:07:32 +0100 Subject: [PATCH 3/3] Changelog updated --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 21cb034..d23593c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog 2.7.0 (unreleased) ------------------ +- #85 Allow field projection - #81 Support second-level precision on searches against DateIndex - #83 Fetch multiple items by UID - #80 Precise timestamp filtering and sorting for created/modified fields