From c3715a961fc732c43003a3dfc1ae9f31ea489eec Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Wed, 10 Jun 2026 14:17:58 +0300 Subject: [PATCH] Fix filterResource collapsing nested JsonPointers to leaf names Resources.filterResource(JsonValue, Collection) built the projection using field.leaf() as a flat key, so a nested pointer such as "manager/userName" lost its nesting and overwrote the same-named top-level field. Requesting _fields=userName,manager,manager/userName caused the top-level userName to be replaced by manager/userName. Use JsonValue.putPermissive(field, value) so each requested pointer is written back under its full path, preserving nesting and creating parent objects on demand. Same-named leaf fields at different nesting levels no longer collide. The empty pointer still copies all fields, shallow-copy semantics are kept, and unresolved (e.g. array-index) pointers are skipped without regression. Update the Javadoc to document the nesting-preserving behavior and extend ResourcesTest with a leaf-name collision case ([userName, manager, manager/userName] over { userName: "bjensen", manager: { userName: "jdoe" } }). Refs: https://github.com/OpenIdentityPlatform/OpenIDM/discussions/183 --- .../forgerock/json/resource/Resources.java | 26 +++++++++---- .../json/resource/ResourcesTest.java | 37 +++++++++++++++++-- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/commons/rest/json-resource/src/main/java/org/forgerock/json/resource/Resources.java b/commons/rest/json-resource/src/main/java/org/forgerock/json/resource/Resources.java index 6a4d703d3..f79ee4065 100644 --- a/commons/rest/json-resource/src/main/java/org/forgerock/json/resource/Resources.java +++ b/commons/rest/json-resource/src/main/java/org/forgerock/json/resource/Resources.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2012-2016 ForgeRock AS. + * Portions copyright 2020-2026 3A Systems, LLC */ package org.forgerock.json.resource; @@ -28,7 +29,6 @@ import java.lang.reflect.Method; import java.util.Collection; import java.util.LinkedHashMap; -import java.util.Map; import org.forgerock.api.annotations.CollectionProvider; import org.forgerock.api.annotations.Path; @@ -99,6 +99,15 @@ public static RequestHandler asRequestHandler(final SynchronousRequestHandler sy * NOTE: this method only performs a shallow copy of extracted * fields, so changes to the filtered JSON value may impact the original * JSON value, and vice-versa. + *

+ * The projection preserves the nested structure of the requested fields: + * each requested {@link JsonPointer} is written back under its full path + * rather than being collapsed to its leaf name. As a result, leaf fields + * that share the same name at different levels of nesting do not conflict. + * For example, projecting {@code userName} and {@code manager/userName} + * over { "userName": "bjensen", "manager": { "userName": "jdoe" } } + * yields { "userName": "bjensen", "manager": { "userName": "jdoe" } }, + * i.e. the top-level {@code userName} is not overwritten by the nested one. * * @param resource * The JSON value whose fields are to be filtered. @@ -111,21 +120,24 @@ public static JsonValue filterResource(final JsonValue resource, if (fields.isEmpty() || resource.isNull() || resource.size() == 0) { return resource; } else { - final Map filtered = new LinkedHashMap<>(fields.size()); + final JsonValue filtered = new JsonValue(new LinkedHashMap(fields.size())); for (final JsonPointer field : fields) { if (field.isEmpty()) { // Special case - copy resource fields (assumes Map). - filtered.putAll(resource.asMap()); + filtered.asMap().putAll(resource.asMap()); } else { - // FIXME: what should we do if the field refers to an array element? final JsonValue value = resource.get(field); if (value != null) { - final String key = field.leaf(); - filtered.put(key, value.getObject()); + // Preserve the nested structure of the requested field + // instead of collapsing it to its leaf name. This keeps + // same-named leaf fields at different levels of nesting + // (e.g. "userName" and "manager/userName") from + // overwriting each other. + filtered.putPermissive(field, value.getObject()); } } } - return new JsonValue(filtered); + return filtered; } } diff --git a/commons/rest/json-resource/src/test/java/org/forgerock/json/resource/ResourcesTest.java b/commons/rest/json-resource/src/test/java/org/forgerock/json/resource/ResourcesTest.java index c8eec89b2..d3a26656b 100644 --- a/commons/rest/json-resource/src/test/java/org/forgerock/json/resource/ResourcesTest.java +++ b/commons/rest/json-resource/src/test/java/org/forgerock/json/resource/ResourcesTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2012-2016 ForgeRock AS. + * Portions copyright 2020-2026 3A Systems, LLC */ package org.forgerock.json.resource; @@ -161,25 +162,36 @@ public Object[][] testFilterData() { { filter("/a/b"), content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))), - expected(object(field("b", "1"))) + expected(object(field("a", object(field("b", "1"))))) }, { filter("/a/b", "/d"), content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))), - expected(object(field("b", "1"), field("d", "3"))) + expected(object(field("a", object(field("b", "1"))), field("d", "3"))) }, { filter("/a/b", "/a"), content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))), - expected(object(field("b", "1"), field("a", object(field("b", "1"), field("c", "2"))))) + expected(object(field("a", object(field("b", "1"), field("c", "2"))))) }, { filter("/a", "/a/b"), content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))), - expected(object(field("a", object(field("b", "1"), field("c", "2"))), field("b", "1"))) + expected(object(field("a", object(field("b", "1"), field("c", "2"))))) + }, + + // Same-named leaf fields at different levels of nesting must coexist + // (see OpenIDM discussion #183): the top-level "userName" must not be + // overwritten by the nested "manager/userName". + { + filter("/userName", "/manager", "/manager/userName"), + content(object(field("userName", "bjensen"), + field("manager", object(field("userName", "jdoe"))))), + expected(object(field("userName", "bjensen"), + field("manager", object(field("userName", "jdoe"))))) }, }; @@ -192,6 +204,23 @@ public void testFilter(List filter, JsonValue content, JsonValue ex expected.getObject()); } + @Test + public void testFilterPreservesNestedFieldsWithCollidingLeafNames() { + // Given a resource with a "userName" both at the top level and nested + // inside "manager" (see OpenIDM discussion #183). + final JsonValue content = content(object( + field("userName", "bjensen"), + field("manager", object(field("userName", "jdoe"))))); + + // When projecting userName, manager and manager/userName. + final JsonValue result = Resources.filterResource(content, + filter("/userName", "/manager", "/manager/userName")); + + // Then the nested structure is preserved and the leaf names do not collide. + assertThat(result.get("userName").asString()).isEqualTo("bjensen"); + assertThat(result.get("manager").get("userName").asString()).isEqualTo("jdoe"); + } + @DataProvider public Object[][] testCollectionResourceProviderData() { // @formatter:off