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