From 7dd3fdbfae50e00a21eb3580af5500dfd868300b Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Sun, 19 Jul 2026 18:57:51 +0800 Subject: [PATCH] fix: only generate links when fields expand to href [DHIS2-21856] (2.43) Single-object GET treated any field preset (e.g. :owner) as if href was requested, triggering deep link generation that reflectively initializes every lazy collection of the entity. On objects with very large collections (e.g. a user role with 240k members) this loads and L2-caches every member entity, taking 30+ seconds of CPU per request. Only *, :all and :simple actually expand to the transient href property, so only those (and a literal href field) now trigger link generation. Response payloads are unchanged for all field values. AI Assisted --- .../AbstractCrudControllerTest.java | 24 +++++++++++++++++++ .../AbstractFullReadOnlyController.java | 10 ++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AbstractCrudControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AbstractCrudControllerTest.java index 80115ffeb237..84207596bbdf 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AbstractCrudControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AbstractCrudControllerTest.java @@ -75,6 +75,8 @@ import org.hisp.dhis.user.UserGroup; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.MediaType; import org.springframework.transaction.annotation.Transactional; @@ -105,6 +107,28 @@ void testGetObject() { assertEquals(id, userById.getId()); } + @ParameterizedTest + @ValueSource(strings = {":all", "*", ":simple", "href", "id,href", "id,name,href"}) + @DisplayName("Single object GET returns href when requested directly or via an expanding preset") + void testGetObjectWithHref(String fields) { + String id = GET("/users/").content().getList("users", JsonUser.class).get(0).getId(); + JsonUser user = + GET("/users/{id}?fields={fields}", id, fields).content(HttpStatus.OK).as(JsonUser.class); + String href = user.getString("href").string(); + assertNotNull(href); + assertTrue(href.endsWith("/users/" + id)); + } + + @ParameterizedTest + @ValueSource(strings = {":owner", ":identifiable", ":nameable", ":persisted", "id,name"}) + @DisplayName("Single object GET omits href for presets that do not expand to it") + void testGetObjectWithoutHref(String fields) { + String id = GET("/users/").content().getList("users", JsonUser.class).get(0).getId(); + JsonUser user = + GET("/users/{id}?fields={fields}", id, fields).content(HttpStatus.OK).as(JsonUser.class); + assertFalse(user.has("href")); + } + @Test void testGetObjectProperty() { // response will look like: { "surname": } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AbstractFullReadOnlyController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AbstractFullReadOnlyController.java index e2b89b193b2c..49709276f68d 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AbstractFullReadOnlyController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AbstractFullReadOnlyController.java @@ -541,8 +541,14 @@ private void handleLinksAndAccess(List entityList, List fields, boole private boolean fieldsContains(String match, List fields) { for (String field : fields) { - // for now assume href/access if * or preset is requested - if (field.contains(match) || field.equals("*") || field.startsWith(":")) { + // only presets that expand to the href property need link generation; + // href is transient (not owner/persisted) and not part of the fixed + // identifiable/nameable presets, but it is a simple property, so it is + // included by *, :all and :simple + if (field.contains(match) + || field.equals("*") + || field.equals(":all") + || field.equals(":simple")) { return true; } }