> iterator)
- {
- this.iterator = iterator;
- }
-
public boolean hasNext()
{
return iterator.hasNext();
@@ -301,7 +271,7 @@ public JsonNode remove(@NotNull final Object key)
*/
public void putAll(@NotNull final Map extends String, ? extends JsonNode> m)
{
- for (Entry extends String, ? extends JsonNode> entry : m.entrySet())
+ for (var entry : m.entrySet())
{
attributes.put(new CaseIgnoreKey(entry.getKey()), entry.getValue());
}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreObjectNode.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreObjectNode.java
index 8b423838..148ec68e 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreObjectNode.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreObjectNode.java
@@ -45,10 +45,21 @@
import java.util.List;
import java.util.Map;
-import static com.unboundid.scim2.common.utils.StaticUtils.toLowerCase;
-
/**
- * An ObjectNode with case-insensitive field names.
+ * This class provides an ObjectNode implementation that treats property names
+ * as case-insensitive. Although the {@code ACCEPT_CASE_INSENSITIVE_PROPERTIES}
+ * property is enabled by the SCIM SDK, this is only used for deserialization,
+ * so it does not apply to the Jackson tree model once objects are instantiated.
+ * Thus, this class aligns with the SCIM standard's handling of attribute names.
+ *
+ *
+ * To create an instance, use the {@link JsonUtils} class or a saved JsonMapper:
+ *
+ * ObjectNode caseIgnoreNode = JsonUtils.getJsonNodeFactory().objectNode();
+ *
+ * final JsonMapper mapper = JsonUtils.createJsonMapper();
+ * ObjectNode otherCaseIgnoreNode = mapper.createObjectNode();
+ *
*/
public class CaseIgnoreObjectNode extends ObjectNode
{
@@ -65,170 +76,100 @@ public CaseIgnoreObjectNode(@NotNull final JsonNodeFactory nc)
/**
* Create a new CaseIgnoreObjectNode.
*
- * @param nc The JsonNodeFactory.
- * @param kids The fields to put in this CaseIgnoreObjectNode.
+ * @param nc The JsonNodeFactory.
+ * @param children The fields to put in this CaseIgnoreObjectNode.
*/
public CaseIgnoreObjectNode(@NotNull final JsonNodeFactory nc,
- @NotNull final Map kids)
- {
- super(nc, new CaseIgnoreMap(kids));
- }
-
- /**
- * {@inheritDoc}
- */
- @Override
- @NotNull
- public ObjectNode deepCopy()
- {
- CaseIgnoreObjectNode ret = new CaseIgnoreObjectNode(_nodeFactory);
-
- for (Map.Entry entry : _children.entrySet())
- {
- ret._children.put(entry.getKey(), entry.getValue().deepCopy());
- }
-
- return ret;
- }
-
- /**
- * {@inheritDoc}
- */
- @Override
- @Nullable
- public JsonNode findValue(@NotNull final String fieldName)
+ @NotNull final Map children)
{
- for (Map.Entry entry : _children.entrySet())
- {
- if (fieldName.equals(entry.getKey()))
- {
- return entry.getValue();
- }
- JsonNode value = entry.getValue().findValue(fieldName);
- if (value != null)
- {
- return value;
- }
- }
- return null;
+ super(nc, new CaseIgnoreMap(children));
}
/**
- * Similar to {@link #findValue}, but returns multiple values.
+ * Obtains values of a named JSON property within a nested ObjectNode.
+ * External callers should use {@link #findValues(String)}.
*
- * @param fieldName The name of the JSON field/attribute.
- * @param foundSoFar An optional argument for recursive calls. External
- * callers should set this value to {@code null}.
+ * @param propertyName The name of the JSON field/attribute.
+ * @param foundSoFar An optional argument for recursive calls by Jackson.
*
* @return The list of values.
*/
@Override
@NotNull
- public List findValues(@NotNull final String fieldName,
+ public List findValues(@NotNull final String propertyName,
@Nullable final List foundSoFar)
{
- List localFoundSoFar = foundSoFar;
- for (Map.Entry entry : _children.entrySet())
- {
- if (toLowerCase(fieldName).equals(toLowerCase(entry.getKey())))
- {
- if (localFoundSoFar == null)
- {
- localFoundSoFar = new ArrayList<>();
- }
- localFoundSoFar.add(entry.getValue());
- }
- else
- { // only add children if parent not added
- localFoundSoFar = entry.getValue().findValues(fieldName, foundSoFar);
- }
- }
- return localFoundSoFar;
+ List parsed = findParents(propertyName, null)
+ .stream().map(node -> node.path(propertyName)).toList();
+ return mutableList(foundSoFar, parsed);
}
/**
- * Similar to {@link #findValues}, but invokes {@link #asString()} on each
- * element.
+ * Obtains string values of a named JSON property within a nested ObjectNode.
+ * External callers should use {@link #findValuesAsString(String)}.
*
- * @param fieldName The name of the JSON field/attribute.
- * @param foundSoFar An optional argument to specify known values. External
- * calls generally should set this value to {@code null}.
+ * @param propertyName The name of the JSON field/attribute.
+ * @param foundSoFar An optional argument for recursive calls by Jackson.
*
* @return The list of values.
*/
@Override
@NotNull
public List findValuesAsString(
- @NotNull final String fieldName,
+ @NotNull final String propertyName,
@Nullable final List foundSoFar)
{
- List localFoundSoFar = foundSoFar;
- for (Map.Entry entry : _children.entrySet())
- {
- if (toLowerCase(fieldName).equals(toLowerCase(entry.getKey())))
- {
- if (localFoundSoFar == null)
- {
- localFoundSoFar = new ArrayList<>();
- }
- localFoundSoFar.add(entry.getValue().asString());
- }
- else
- { // only add children if parent not added
- localFoundSoFar = entry.getValue().findValuesAsString(fieldName,
- foundSoFar);
- }
- }
- return localFoundSoFar;
+ List parsed = findParents(propertyName, null)
+ .stream().map(node -> node.path(propertyName).asString()).toList();
+ return mutableList(foundSoFar, parsed);
}
/**
- * {@inheritDoc}
+ * Obtains JSON objects within this node that match the specified field.
+ * External callers should use {@link #findParents(String)}.
+ *
+ * @param propertyName The name of the JSON field/attribute.
+ * @param _found An optional argument for recursive calls.
+ *
+ * @return A list containing all matching nodes.
*/
@Override
- @Nullable
- public ObjectNode findParent(@NotNull final String fieldName)
+ @NotNull
+ public List findParents(@NotNull final String propertyName,
+ @Nullable final List _found)
{
+ // Use a ternary operator to avoid creating a list on every recursive call.
+ List foundSoFar = (_found == null) ? new ArrayList<>() : _found;
+
for (Map.Entry entry : _children.entrySet())
{
- if (toLowerCase(fieldName).equals(toLowerCase(entry.getKey())))
+ // Ensure case-insensitive comparison for CaseIgnoreObjectNode.
+ if (propertyName.equalsIgnoreCase(entry.getKey()))
{
- return this;
+ foundSoFar.add(this);
}
- JsonNode value = entry.getValue().findParent(fieldName);
- if (value != null)
+ else
{
- return (ObjectNode) value;
+ foundSoFar = entry.getValue().findParents(propertyName, foundSoFar);
}
}
- return null;
+
+ return foundSoFar;
}
- /**
- * {@inheritDoc}
- */
- @Override
+ @SafeVarargs
@NotNull
- public List findParents(@NotNull final String fieldName,
- @Nullable final List foundSoFar)
+ private static List mutableList(@NotNull final List... lists)
{
- List localFoundSoFar = foundSoFar;
- for (Map.Entry entry : _children.entrySet())
+ List returnList = new ArrayList<>();
+ for (List list : lists)
{
- if (toLowerCase(fieldName).equals(toLowerCase(entry.getKey())))
+ if (list != null)
{
- if (localFoundSoFar == null)
- {
- localFoundSoFar = new ArrayList<>();
- }
- localFoundSoFar.add(this);
- }
- else
- { // only add children if parent not added
- localFoundSoFar = entry.getValue()
- .findParents(fieldName, foundSoFar);
+ returnList.addAll(list);
}
}
- return localFoundSoFar;
+
+ return returnList;
}
}
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/CaseIgnoreObjectNodeTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/CaseIgnoreObjectNodeTest.java
new file mode 100644
index 00000000..6c7a8a5c
--- /dev/null
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/CaseIgnoreObjectNodeTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common;
+
+import com.unboundid.scim2.common.utils.CaseIgnoreObjectNode;
+import com.unboundid.scim2.common.utils.JsonUtils;
+import org.testng.annotations.Test;
+import tools.jackson.databind.node.ObjectNode;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static tools.jackson.databind.node.StringNode.valueOf;
+
+
+/**
+ * Tests for {@link CaseIgnoreObjectNode}.
+ */
+public class CaseIgnoreObjectNodeTest
+{
+ /**
+ * This test method validates basic compatibility. In particular, the test
+ * checks that standard ObjectNode behavior is preserved, along with the
+ * treatment of all fields as case-insensitive.
+ *
+ *
+ * Note that some additional parent methods are tested alongside methods
+ * explicitly defined in the subclass. This ensures that these perform in a
+ * case-insensitive way even without an explicit override.
+ */
+ @Test
+ public void testBasic()
+ {
+ String nestedJson = """
+ {
+ "Weird": {
+ "Not": "Here"
+ },
+ "I'm": [ "Zombie", "Body", "Train", "Track" ],
+ "Dirty": "Rotten",
+ "Colors": "flat",
+ "Sad": {
+ "Shell": {
+ "Woman": {
+ "Maggots": "brains"
+ }
+ }
+ },
+ "Just": {
+ "Happens": {
+ "When": {
+ "Baby": "Away",
+ "He": "Away"
+ }
+ }
+ }
+ }""";
+
+ // Initialize the object node with the above JSON data.
+ CaseIgnoreObjectNode node = JsonUtils.getObjectReader()
+ .forType(CaseIgnoreObjectNode.class)
+ .readValue(nestedJson);
+
+ // Test findValue() using different casing for the properties.
+ assertThat(node.findValue("DIRTY").asString()).isEqualTo("Rotten");
+ assertThat(node.findValue("colors").asString()).isEqualTo("flat");
+
+ // Test findValues().
+ assertThat(node.findValues("he")).containsOnly(valueOf("Away"));
+ assertThat(node.findValues("NOT")).containsOnly(valueOf("Here"));
+ assertThat(node.findValues("maggots", List.of(valueOf("for"))))
+ .containsExactly(valueOf("for"), valueOf("brains"));
+
+ // Test findValuesAsString().
+ assertThat(node.findValuesAsString("dirty")).containsOnly("Rotten");
+ assertThat(node.findValuesAsString("COLORS")).containsOnly("flat");
+ assertThat(node.findValuesAsString("baby", List.of("goes")))
+ .containsExactly("goes", "Away");
+
+ // Test findParent(), which obtains the reference to a nested JsonNode
+ // object when it is given a string property name.
+ assertThat(node.findParent("baby"))
+ .isEqualTo(objectNode().put("Baby", "Away").put("He", "Away"));
+
+ // Test findParents().
+ assertThat(node.findParents("NOT"))
+ .containsOnly(objectNode().put("Not", "Here"));
+
+ // Test deepCopy().
+ assertThat(node.deepCopy())
+ .isInstanceOf(CaseIgnoreObjectNode.class)
+ .isEqualTo(node)
+ .isNotSameAs(node);
+ }
+
+ private ObjectNode objectNode()
+ {
+ return JsonUtils.getJsonNodeFactory().objectNode();
+ }
+}
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/GenericScimResourceTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/GenericScimResourceTest.java
index d8c3b27b..5e0102ca 100644
--- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/GenericScimResourceTest.java
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/GenericScimResourceTest.java
@@ -38,6 +38,7 @@
import com.unboundid.scim2.common.types.GroupResource;
import com.unboundid.scim2.common.types.Meta;
import com.unboundid.scim2.common.types.UserResource;
+import com.unboundid.scim2.common.utils.CaseIgnoreObjectNode;
import com.unboundid.scim2.common.utils.DateTimeUtils;
import com.unboundid.scim2.common.utils.JsonUtils;
import org.testng.Assert;
@@ -45,6 +46,7 @@
import tools.jackson.core.Base64Variants;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.node.StringNode;
@@ -54,6 +56,7 @@
import java.util.Date;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
@@ -63,11 +66,64 @@
import static org.testng.Assert.assertNotEquals;
/**
- * Tests generic scim objects.
+ * Tests for {@link GenericScimResource}.
*/
@Test
public class GenericScimResourceTest
{
+ /**
+ * Validate case-insensitive paths for generic SCIM resources.
+ */
+ @Test
+ public void testCasing() throws Exception
+ {
+ // Initialize a generic SCIM resource. The underlying object node should
+ // treat path arguments in a case-insensitive manner.
+ final GenericScimResource bone = new GenericScimResource()
+ .replaceValue("Bone-dry", "Bitter");
+ assertThat(bone.getObjectNode()).isInstanceOf(CaseIgnoreObjectNode.class);
+ assertThat(bone.getStringValue("bone-dry")).isEqualTo("Bitter");
+ assertThat(bone.getStringValue("BONE-DRY")).isEqualTo("Bitter");
+
+ // Test the constructor that accepts an object node directly. The underlying
+ // object node on the SCIM resource should always use case-insensitive keys
+ // even if the source object node does not.
+ ObjectNode node = new JsonMapper().createObjectNode()
+ .put("And", "Hollow");
+ assertThat(node).isNotInstanceOf(CaseIgnoreObjectNode.class);
+ final GenericScimResource hollow = new GenericScimResource(node);
+ assertThat(hollow.getObjectNode())
+ .isInstanceOf(CaseIgnoreObjectNode.class);
+ assertThat(hollow.getStringValue("and")).isEqualTo("Hollow");
+ assertThat(hollow.getStringValue("AND")).isEqualTo("Hollow");
+
+ // Test the same behavior with a dedicated CaseIgnoreObjectNode.
+ var ciNode = new CaseIgnoreObjectNode(JsonUtils.getJsonNodeFactory(),
+ Map.of("And", StringNode.valueOf("Hollow")));
+ assertThat(new GenericScimResource(ciNode).getStringValue("and"))
+ .isEqualTo("Hollow");
+
+ // Deserialized resources should also use case-insensitive paths. Validate a
+ // standard JSON mapper without SCIM SDK configuration.
+ String json = """
+ {
+ "Be": {
+ "Miles": {
+ "Away": "Tomorrow"
+ }
+ }
+ }""";
+ final GenericScimResource nested = new JsonMapper().reader()
+ .forType(GenericScimResource.class).readValue(json);
+ assertThat(nested.getStringValue("be.miles.away"))
+ .isEqualTo("Tomorrow");
+ assertThat(nested.getStringValue("BE.MILES.AWAY"))
+ .isEqualTo("Tomorrow");
+ assertThat(nested.getObjectNode().path("be").path("miles"))
+ .isInstanceOf(CaseIgnoreObjectNode.class);
+ }
+
+
/**
* Tests parsing a json string into a GenericScimObject.
*/