Skip to content

Commit 3bb9a67

Browse files
authored
Fix filterResource collapsing nested JsonPointers to leaf names (#183)
Resources.filterResource(JsonValue, Collection<JsonPointer>) 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: OpenIdentityPlatform/OpenIDM#183
1 parent e3de223 commit 3bb9a67

2 files changed

Lines changed: 52 additions & 11 deletions

File tree

commons/rest/json-resource/src/main/java/org/forgerock/json/resource/Resources.java

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* information: "Portions copyright [year] [name of copyright owner]".
1313
*
1414
* Copyright 2012-2016 ForgeRock AS.
15+
* Portions copyright 2020-2026 3A Systems, LLC
1516
*/
1617

1718
package org.forgerock.json.resource;
@@ -28,7 +29,6 @@
2829
import java.lang.reflect.Method;
2930
import java.util.Collection;
3031
import java.util.LinkedHashMap;
31-
import java.util.Map;
3232

3333
import org.forgerock.api.annotations.CollectionProvider;
3434
import org.forgerock.api.annotations.Path;
@@ -99,6 +99,15 @@ public static RequestHandler asRequestHandler(final SynchronousRequestHandler sy
9999
* <b>NOTE:</b> this method only performs a shallow copy of extracted
100100
* fields, so changes to the filtered JSON value may impact the original
101101
* JSON value, and vice-versa.
102+
* <p>
103+
* The projection preserves the nested structure of the requested fields:
104+
* each requested {@link JsonPointer} is written back under its full path
105+
* rather than being collapsed to its leaf name. As a result, leaf fields
106+
* that share the same name at different levels of nesting do not conflict.
107+
* For example, projecting {@code userName} and {@code manager/userName}
108+
* over <code>{ "userName": "bjensen", "manager": { "userName": "jdoe" } }</code>
109+
* yields <code>{ "userName": "bjensen", "manager": { "userName": "jdoe" } }</code>,
110+
* i.e. the top-level {@code userName} is not overwritten by the nested one.
102111
*
103112
* @param resource
104113
* The JSON value whose fields are to be filtered.
@@ -111,21 +120,24 @@ public static JsonValue filterResource(final JsonValue resource,
111120
if (fields.isEmpty() || resource.isNull() || resource.size() == 0) {
112121
return resource;
113122
} else {
114-
final Map<String, Object> filtered = new LinkedHashMap<>(fields.size());
123+
final JsonValue filtered = new JsonValue(new LinkedHashMap<String, Object>(fields.size()));
115124
for (final JsonPointer field : fields) {
116125
if (field.isEmpty()) {
117126
// Special case - copy resource fields (assumes Map).
118-
filtered.putAll(resource.asMap());
127+
filtered.asMap().putAll(resource.asMap());
119128
} else {
120-
// FIXME: what should we do if the field refers to an array element?
121129
final JsonValue value = resource.get(field);
122130
if (value != null) {
123-
final String key = field.leaf();
124-
filtered.put(key, value.getObject());
131+
// Preserve the nested structure of the requested field
132+
// instead of collapsing it to its leaf name. This keeps
133+
// same-named leaf fields at different levels of nesting
134+
// (e.g. "userName" and "manager/userName") from
135+
// overwriting each other.
136+
filtered.putPermissive(field, value.getObject());
125137
}
126138
}
127139
}
128-
return new JsonValue(filtered);
140+
return filtered;
129141
}
130142
}
131143

commons/rest/json-resource/src/test/java/org/forgerock/json/resource/ResourcesTest.java

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* information: "Portions copyright [year] [name of copyright owner]".
1313
*
1414
* Copyright 2012-2016 ForgeRock AS.
15+
* Portions copyright 2020-2026 3A Systems, LLC
1516
*/
1617

1718
package org.forgerock.json.resource;
@@ -161,25 +162,36 @@ public Object[][] testFilterData() {
161162
{
162163
filter("/a/b"),
163164
content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))),
164-
expected(object(field("b", "1")))
165+
expected(object(field("a", object(field("b", "1")))))
165166
},
166167

167168
{
168169
filter("/a/b", "/d"),
169170
content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))),
170-
expected(object(field("b", "1"), field("d", "3")))
171+
expected(object(field("a", object(field("b", "1"))), field("d", "3")))
171172
},
172173

173174
{
174175
filter("/a/b", "/a"),
175176
content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))),
176-
expected(object(field("b", "1"), field("a", object(field("b", "1"), field("c", "2")))))
177+
expected(object(field("a", object(field("b", "1"), field("c", "2")))))
177178
},
178179

179180
{
180181
filter("/a", "/a/b"),
181182
content(object(field("a", object(field("b", "1"), field("c", "2"))), field("d", "3"))),
182-
expected(object(field("a", object(field("b", "1"), field("c", "2"))), field("b", "1")))
183+
expected(object(field("a", object(field("b", "1"), field("c", "2")))))
184+
},
185+
186+
// Same-named leaf fields at different levels of nesting must coexist
187+
// (see OpenIDM discussion #183): the top-level "userName" must not be
188+
// overwritten by the nested "manager/userName".
189+
{
190+
filter("/userName", "/manager", "/manager/userName"),
191+
content(object(field("userName", "bjensen"),
192+
field("manager", object(field("userName", "jdoe"))))),
193+
expected(object(field("userName", "bjensen"),
194+
field("manager", object(field("userName", "jdoe")))))
183195
},
184196

185197
};
@@ -192,6 +204,23 @@ public void testFilter(List<JsonPointer> filter, JsonValue content, JsonValue ex
192204
expected.getObject());
193205
}
194206

207+
@Test
208+
public void testFilterPreservesNestedFieldsWithCollidingLeafNames() {
209+
// Given a resource with a "userName" both at the top level and nested
210+
// inside "manager" (see OpenIDM discussion #183).
211+
final JsonValue content = content(object(
212+
field("userName", "bjensen"),
213+
field("manager", object(field("userName", "jdoe")))));
214+
215+
// When projecting userName, manager and manager/userName.
216+
final JsonValue result = Resources.filterResource(content,
217+
filter("/userName", "/manager", "/manager/userName"));
218+
219+
// Then the nested structure is preserved and the leaf names do not collide.
220+
assertThat(result.get("userName").asString()).isEqualTo("bjensen");
221+
assertThat(result.get("manager").get("userName").asString()).isEqualTo("jdoe");
222+
}
223+
195224
@DataProvider
196225
public Object[][] testCollectionResourceProviderData() {
197226
// @formatter:off

0 commit comments

Comments
 (0)