Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<compileSource>17</compileSource>
<main.basedir>${project.basedir}</main.basedir>
<jackson.version>3.1.3</jackson.version>
<jackson-annotations.version>2.21</jackson-annotations.version>
<jackson.version>3.2.0</jackson.version>
<jackson-annotations.version>2.22</jackson-annotations.version>
<jakarta-rs.version>4.0.0</jakarta-rs.version>
<jersey.version>4.0.2</jersey.version>
<testng.version>7.12.0</testng.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, JsonNode> map = JsonUtils.getObjectReader()
.forType(new TypeReference<Map<String, JsonNode>>(){})
.readValue(objectNode);
node = new CaseIgnoreObjectNode(JsonUtils.getJsonNodeFactory(), map);
}

this.objectNode = node;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,8 @@ public class CaseIgnoreMap implements Map<String, JsonNode>
* 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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to use records for these internal classes to reduce boiler plate.


CaseIgnoreKey(@NotNull final String key)
{
this.key = key;
}

@NotNull
public String getKey()
{
Expand All @@ -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
Expand Down Expand Up @@ -126,16 +111,9 @@ public int size()
/**
* Iterator for the keys.
*/
private static class KeyIterator implements Iterator<String>
private record KeyIterator(@NotNull Iterator<CaseIgnoreKey> iterator)
implements Iterator<String>
{
@NotNull
private final Iterator<CaseIgnoreKey> iterator;

KeyIterator(@NotNull final Iterator<CaseIgnoreKey> iterator)
{
this.iterator = iterator;
}

public boolean hasNext()
{
return iterator.hasNext();
Expand Down Expand Up @@ -183,18 +161,10 @@ public int size()
/**
* Iterator for map entries.
*/
private static class EntryIterator
implements Iterator<Entry<String, JsonNode>>
private record EntryIterator(
@NotNull Iterator<Entry<CaseIgnoreKey, JsonNode>> iterator)
implements Iterator<Entry<String, JsonNode>>
{
@NotNull
private final Iterator<Entry<CaseIgnoreKey, JsonNode>> iterator;

EntryIterator(
@NotNull final Iterator<Entry<CaseIgnoreKey, JsonNode>> iterator)
{
this.iterator = iterator;
}

public boolean hasNext()
{
return iterator.hasNext();
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <br><br>
*
* To create an instance, use the {@link JsonUtils} class or a saved JsonMapper:
* <pre><code>
* ObjectNode caseIgnoreNode = JsonUtils.getJsonNodeFactory().objectNode();
*
* final JsonMapper mapper = JsonUtils.createJsonMapper();
* ObjectNode otherCaseIgnoreNode = mapper.createObjectNode();
* </code></pre>
*/
public class CaseIgnoreObjectNode extends ObjectNode
{
Expand All @@ -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<String, JsonNode> kids)
{
super(nc, new CaseIgnoreMap(kids));
}

/**
* {@inheritDoc}
*/
@Override
@NotNull
public ObjectNode deepCopy()
{
CaseIgnoreObjectNode ret = new CaseIgnoreObjectNode(_nodeFactory);

for (Map.Entry<String, JsonNode> entry : _children.entrySet())
{
ret._children.put(entry.getKey(), entry.getValue().deepCopy());
}

return ret;
}

/**
* {@inheritDoc}
*/
@Override
@Nullable
public JsonNode findValue(@NotNull final String fieldName)

@kqarryzada kqarryzada Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jackson helpfully routes calls like this to the plural variant and returns the first result, so the explicit method override is not needed. It's a tradeoff between maintainability and performance (i.e., walking the whole tree), but I don't think there's that much overhead with this approach.

@NotNull final Map<String, JsonNode> children)
{
for (Map.Entry<String, JsonNode> 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<JsonNode> findValues(@NotNull final String fieldName,
public List<JsonNode> findValues(@NotNull final String propertyName,
@Nullable final List<JsonNode> foundSoFar)
{
List<JsonNode> localFoundSoFar = foundSoFar;
for (Map.Entry<String, JsonNode> 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<JsonNode> parsed = findParents(propertyName, null)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These explicitly pass null to call our method directly instead of relying on Jackson to do it.

.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<String> findValuesAsString(
@NotNull final String fieldName,
@NotNull final String propertyName,
@Nullable final List<String> foundSoFar)
{
List<String> localFoundSoFar = foundSoFar;
for (Map.Entry<String, JsonNode> 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<String> 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<JsonNode> findParents(@NotNull final String propertyName,
@Nullable final List<JsonNode> _found)
{
// Use a ternary operator to avoid creating a list on every recursive call.
List<JsonNode> foundSoFar = (_found == null) ? new ArrayList<>() : _found;

for (Map.Entry<String, JsonNode> 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<JsonNode> findParents(@NotNull final String fieldName,
@Nullable final List<JsonNode> foundSoFar)
private static <T> List<T> mutableList(@NotNull final List<T>... lists)
{
List<JsonNode> localFoundSoFar = foundSoFar;
for (Map.Entry<String, JsonNode> entry : _children.entrySet())
List<T> returnList = new ArrayList<>();
for (List<T> 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;
}
}
Loading
Loading