diff --git a/CHANGELOG.md b/CHANGELOG.md index 284019f5..231c7651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). Fixed an issue with deserializing a GenericScimResource object when it was embedded within a list response. +Update Jackson from 3.13.0 to 3.20.0 and jackson-annotations to from 2.21 to 2.22. + +Fixed an issue where GenericScimResource would use case-sensitive property names if the resource was +initialized with an ObjectNode that was not a SCIM SDK `CaseIgnoreObjectNode`. + ## 6.0.0 - 2026-May-11 The UnboundID SCIM SDK has been updated to use version 3 of the Jackson library (this release ships with v3.1.3). This change aligns the SCIM SDK with HTTP libraries such as Spring Framework 7/Spring diff --git a/pom.xml b/pom.xml index 41f70133..3ac03017 100644 --- a/pom.xml +++ b/pom.xml @@ -84,8 +84,8 @@ UTF-8 17 ${project.basedir} - 3.1.3 - 2.21 + 3.2.0 + 2.22 4.0.0 4.0.2 7.12.0 diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java index 97106088..da8c678c 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java @@ -37,10 +37,12 @@ import com.unboundid.scim2.common.exceptions.ScimException; import com.unboundid.scim2.common.exceptions.ServerErrorException; import com.unboundid.scim2.common.types.Meta; +import com.unboundid.scim2.common.utils.CaseIgnoreObjectNode; import com.unboundid.scim2.common.utils.GenericScimObjectDeserializer; import com.unboundid.scim2.common.utils.GenericScimObjectSerializer; import com.unboundid.scim2.common.utils.JsonUtils; import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; import tools.jackson.databind.JsonNode; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.annotation.JsonSerialize; @@ -56,6 +58,7 @@ import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Objects; import static com.unboundid.scim2.common.utils.StaticUtils.toList; @@ -141,7 +144,20 @@ public GenericScimResource() */ public GenericScimResource(@NotNull final ObjectNode objectNode) { - this.objectNode = objectNode; + CaseIgnoreObjectNode node; + if (objectNode instanceof CaseIgnoreObjectNode c) + { + node = c; + } + else + { + Map map = JsonUtils.getObjectReader() + .forType(new TypeReference>(){}) + .readValue(objectNode); + node = new CaseIgnoreObjectNode(JsonUtils.getJsonNodeFactory(), map); + } + + this.objectNode = node; } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreMap.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreMap.java index aa6d6426..6aed2813 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreMap.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/CaseIgnoreMap.java @@ -55,16 +55,8 @@ public class CaseIgnoreMap implements Map * A wrapper around the standard String but compares and hashes them * in lower-case. */ - private static class CaseIgnoreKey + private record CaseIgnoreKey(@NotNull String key) { - @NotNull - private final String key; - - CaseIgnoreKey(@NotNull final String key) - { - this.key = key; - } - @NotNull public String getKey() { @@ -78,15 +70,8 @@ public boolean equals(@Nullable final Object o) { return true; } - if (o == null || getClass() != o.getClass()) - { - return false; - } - - CaseIgnoreKey that = (CaseIgnoreKey) o; - - return toLowerCase(key).equals(toLowerCase(that.key)); + return o instanceof CaseIgnoreKey that && key.equalsIgnoreCase(that.key); } @Override @@ -126,16 +111,9 @@ public int size() /** * Iterator for the keys. */ - private static class KeyIterator implements Iterator + private record KeyIterator(@NotNull Iterator iterator) + implements Iterator { - @NotNull - private final Iterator iterator; - - KeyIterator(@NotNull final Iterator iterator) - { - this.iterator = iterator; - } - public boolean hasNext() { return iterator.hasNext(); @@ -183,18 +161,10 @@ public int size() /** * Iterator for map entries. */ - private static class EntryIterator - implements Iterator> + private record EntryIterator( + @NotNull Iterator> iterator) + implements Iterator> { - @NotNull - private final Iterator> iterator; - - EntryIterator( - @NotNull final Iterator> 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 m) { - for (Entry 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. */