diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/MapperFactory.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/MapperFactory.java
index a1ac527a..ea7844b6 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/MapperFactory.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/MapperFactory.java
@@ -24,6 +24,7 @@
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.unboundid.scim2.common.annotations.NotNull;
@@ -32,29 +33,97 @@
import java.util.Date;
import java.util.Map;
+import static com.fasterxml.jackson.databind.MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES;
+
/**
- * Class used to customize the object mapper that is used by the SCIM 2 SDK.
+ * This class may be used to customize the object mapper that is used by the
+ * SCIM SDK.
+ *
+ *
+ * The SCIM SDK uses a Jackson {@link ObjectMapper} to convert SCIM resources
+ * between JSON strings and Plain Old Java Objects such as
+ * {@link com.unboundid.scim2.common.types.UserResource}. This object mapper is
+ * configured with specific settings to benefit applications that use the SCIM
+ * SDK. For example, when converting a Java object to a JSON string, the SCIM
+ * SDK will ignore {@code null} fields from the object.
+ *
+ *
+ * If your project would benefit from enabling or disabling certain Jackson
+ * features on the SCIM SDK's object mapper, use one of the following methods:
+ *
+ * - {@link #setMapperCustomFeatures}
+ *
- {@link #setDeserializationCustomFeatures}
+ *
- {@link #setSerializationCustomFeatures}
+ *
- {@link #setJsonParserCustomFeatures}
+ *
- {@link #setJsonGeneratorCustomFeatures}
+ *
+ *
+ * For example, to disable the
+ * {@link MapperFeature#ACCEPT_CASE_INSENSITIVE_PROPERTIES} property, use the
+ * following Java code:
+ *
+ * MapperFactory newFactory = new MapperFactory();
+ * newFactory.setMapperCustomFeatures(
+ * Map.of(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, false)
+ * );
+ *
+ * // Register the new MapperFactory with the SCIM SDK.
+ * JsonUtils.setCustomMapperFactory(newFactory);
+ *
+ *
+ * If your desired customization is more complicated than enabling/disabling
+ * Jackson features, an alternative is to create a custom {@code MapperFactory}
+ * class that overrides the behavior of {@link #createObjectMapper}. When
+ * overriding this method, the subclass must first fetch an object
+ * mapper from the superclass to ensure that the SCIM SDK's original
+ * configuration is preserved. For example:
+ *
+ * public class CustomMapperFactory extends MapperFactory
+ * {
+ * {@literal @}Override
+ * public ObjectMapper createObjectMapper()
+ * {
+ * // Fetch the initial object mapper from the superclass, then add your
+ * // customizations. Do not instantiate a new ObjectMapper.
+ * ObjectMapper mapper = super.createObjectMapper();
+ *
+ * // Add the desired customizations.
+ * SimpleModule module = new SimpleModule();
+ * module.addSerializer(DesiredClass.class, new CustomSerializer());
+ * module.addDeserializer(DesiredClass.class, new CustomDeserializer());
+ * mapper.registerModule(module);
+ *
+ * return mapper;
+ * }
+ * }
+ *
+ *
+ * When your application starts up, register your customer mapper factory with
+ * the SCIM SDK to use the object mapper returned by the custom class:
+ *
+ * JsonUtils.setCustomMapperFactory(new CustomMapperFactory());
+ *
*/
public class MapperFactory
{
@NotNull
- private static Map deserializationCustomFeatures =
+ private Map deserializationCustomFeatures =
Collections.emptyMap();
@NotNull
- private static Map jsonParserCustomFeatures =
+ private Map jsonParserCustomFeatures =
Collections.emptyMap();
@NotNull
- private static Map jsonGeneratorCustomFeatures =
+ private Map jsonGeneratorCustomFeatures =
Collections.emptyMap();
@NotNull
- private static Map mapperCustomFeatures =
+ private Map mapperCustomFeatures =
Collections.emptyMap();
@NotNull
- private static Map serializationCustomFeatures =
+ private Map serializationCustomFeatures =
Collections.emptyMap();
/**
@@ -152,9 +221,20 @@ public MapperFactory setSerializationCustomFeatures(
* and deserializing SCIM JSON objects.
*/
@NotNull
- public static ObjectMapper createObjectMapper()
+ public ObjectMapper createObjectMapper()
{
- ObjectMapper mapper = new ObjectMapper(new ScimJsonFactory());
+ // Create a new object mapper with case-insensitive settings.
+ var objectMapperBuilder = JsonMapper.builder(new ScimJsonFactory());
+
+ // Do not care about case when de-serializing POJOs.
+ objectMapperBuilder.enable(ACCEPT_CASE_INSENSITIVE_PROPERTIES);
+
+ // Add any custom mapper features. This must be done before other fields
+ // (e.g., serializationCustomFeatures) because it must be configured on the
+ // builder object.
+ mapperCustomFeatures.forEach(objectMapperBuilder::configure);
+
+ final ObjectMapper mapper = objectMapperBuilder.build();
// Don't serialize POJO nulls as JSON nulls.
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
@@ -168,36 +248,16 @@ public static ObjectMapper createObjectMapper()
dateTimeModule.addDeserializer(Date.class, new DateDeserializer());
mapper.registerModule(dateTimeModule);
- // Do not care about case when de-serializing POJOs.
- mapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);
-
// Use the case-insensitive JsonNodes.
mapper.setNodeFactory(new ScimJsonNodeFactory());
- for (DeserializationFeature feature : deserializationCustomFeatures.keySet())
- {
- mapper.configure(feature, deserializationCustomFeatures.get(feature));
- }
-
- for (JsonGenerator.Feature feature : jsonGeneratorCustomFeatures.keySet())
- {
- mapper.configure(feature, jsonGeneratorCustomFeatures.get(feature));
- }
-
- for (JsonParser.Feature feature : jsonParserCustomFeatures.keySet())
- {
- mapper.configure(feature, jsonParserCustomFeatures.get(feature));
- }
-
- for (MapperFeature feature : mapperCustomFeatures.keySet())
- {
- mapper.configure(feature, mapperCustomFeatures.get(feature));
- }
-
- for (SerializationFeature feature : serializationCustomFeatures.keySet())
- {
- mapper.configure(feature, serializationCustomFeatures.get(feature));
- }
+ // Configure the custom Jackson features for object mappers created and used
+ // by the SCIM SDK. This step is performed last to ensure that
+ // customizations are not overwritten.
+ deserializationCustomFeatures.forEach(mapper::configure);
+ jsonGeneratorCustomFeatures.forEach(mapper::configure);
+ jsonParserCustomFeatures.forEach(mapper::configure);
+ serializationCustomFeatures.forEach(mapper::configure);
return mapper;
}
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/JsonUtilsTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/JsonUtilsTestCase.java
index 4fc1e419..8ba245a9 100644
--- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/JsonUtilsTestCase.java
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/JsonUtilsTestCase.java
@@ -19,18 +19,14 @@
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
-import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.google.common.collect.ImmutableMap;
import com.unboundid.scim2.common.exceptions.ScimException;
import com.unboundid.scim2.common.filters.Filter;
-import com.unboundid.scim2.common.types.Name;
import com.unboundid.scim2.common.utils.DateTimeUtils;
import com.unboundid.scim2.common.utils.JsonUtils;
-import com.unboundid.scim2.common.utils.MapperFactory;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -1180,38 +1176,6 @@ public Object[][] getPathExistsParams() throws Exception
};
}
- /**
- * Test that setting a custom object mapper factory allows for custom
- * options such as setting fail on unknown properties deserialization option
- * to false (defaults to true).
- *
- * @throws Exception if an error occurs.
- */
- @Test
- public void testCustomMapper() throws Exception
- {
- MapperFactory mapperFactory = new MapperFactory();
- mapperFactory.setDeserializationCustomFeatures(
- ImmutableMap.builder().
- put(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, Boolean.FALSE).build());
- JsonUtils.setCustomMapperFactory(mapperFactory);
-
- String jsonNameString =
- "{" +
- "\"familyName\":\"Smith\"," +
- "\"givenName\":\"Bob\"," +
- "\"middleName\":\"X\"," +
- "\"bogusField\":\"bogusValue\"" +
- "}";
-
- Name name = JsonUtils.getObjectReader().
- forType(Name.class).readValue(jsonNameString);
-
- Assert.assertEquals(name.getFamilyName(), "Smith");
- Assert.assertEquals(name.getGivenName(), "Bob");
- Assert.assertEquals(name.getMiddleName(), "X");
- }
-
/**
* Test that the SCIM 2 SDK ObjectMapper ignores null map values.
*/
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/MapperFactoryTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/MapperFactoryTest.java
new file mode 100644
index 00000000..f9d2c88f
--- /dev/null
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/MapperFactoryTest.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2024 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.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.TextNode;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.exceptions.BadRequestException;
+import com.unboundid.scim2.common.filters.Filter;
+import com.unboundid.scim2.common.filters.FilterType;
+import com.unboundid.scim2.common.types.Email;
+import com.unboundid.scim2.common.types.Name;
+import com.unboundid.scim2.common.types.UserResource;
+import com.unboundid.scim2.common.utils.JsonUtils;
+import com.unboundid.scim2.common.utils.MapperFactory;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.Test;
+
+import java.util.Map;
+
+import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES;
+import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
+import static com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY;
+import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+/**
+ * This class contains tests that validate customization of the
+ * {@link MapperFactory} and its object mapper.
+ */
+public class MapperFactoryTest
+{
+ /**
+ * Reset the mapper factory configuration to the default settings.
+ */
+ @AfterMethod
+ public void tearDown()
+ {
+ JsonUtils.setCustomMapperFactory(new MapperFactory());
+ }
+
+ /**
+ * Tests a custom {@link com.fasterxml.jackson.databind.MapperFeature} setting
+ * on a mapper factory.
+ *
+ * @throws Exception If an unexpected error occurs.
+ */
+ @Test
+ public void testCustomMapperFeatures() throws Exception
+ {
+ // A SCIM resource with the attributes (except 'schema') sorted
+ // alphabetically.
+ final String rawJSONString = "{"
+ + " \"schemas\" : [ \"urn:ietf:params:scim:schemas:core:2.0:User\" ],"
+ + " \"displayName\" : \"Kendrick Lamar\","
+ + " \"emails\" : [{ \"value\" : \"NLU@example.com\" }],"
+ + " \"userName\" : \"K.Dot\""
+ + "}";
+
+ // Reformat the string in a standardized form.
+ final String expectedJSON = JsonUtils.getObjectReader()
+ .readTree(rawJSONString).toString();
+
+ UserResource user = new UserResource()
+ .setUserName("K.Dot")
+ .setEmails(new Email().setValue("NLU@example.com"))
+ .setDisplayName("Kendrick Lamar");
+
+ // By default, the 'userName' field appears before fields like 'email'.
+ // Verify that the serialized user resource does not list attributes in
+ // alphabetical order.
+ String userJSON = JsonUtils.getObjectWriter().writeValueAsString(user);
+ assertThat(userJSON).isNotEqualTo(expectedJSON);
+
+ // Update the object mapper to sort the elements of a SCIM resource.
+ MapperFactory factory = new MapperFactory().setMapperCustomFeatures(
+ Map.of(SORT_PROPERTIES_ALPHABETICALLY, true)
+ );
+ JsonUtils.setCustomMapperFactory(factory);
+
+ // Serialize the user resource again. This time, the object mapper should
+ // sort the fields alphabetically.
+ userJSON = JsonUtils.getObjectWriter().writeValueAsString(user);
+ assertThat(userJSON).isEqualTo(expectedJSON);
+ }
+
+ /**
+ * Tests a custom deserialization setting on a mapper factory.
+ *
+ * @throws Exception If an unexpected error occurs.
+ */
+ @Test
+ public void testCustomDeserializationFeatures() throws Exception
+ {
+ // The JSON representing the 'name' field for a UserResource. The
+ // 'stageName' field is not established by the SCIM standard.
+ final String rawJSONString = "{"
+ + " \"familyName\": \"Duckworth\","
+ + " \"givenName\": \"Kendrick\","
+ + " \"middleName\": \"Lamar\","
+ + " \"formatted\": \"Kendrick Lamar Duckworth\","
+ + " \"stageName\": \"K.Dot\""
+ + "}";
+
+ Name expectedPOJO = new Name().setFamilyName("Duckworth")
+ .setGivenName("Kendrick")
+ .setMiddleName("Lamar")
+ .setFormatted("Kendrick Lamar Duckworth");
+
+ // The default configuration should not allow the unknown field.
+ assertThatThrownBy(() ->
+ JsonUtils.getObjectReader().forType(Name.class).readValue(rawJSONString)
+ ).isInstanceOf(JsonProcessingException.class);
+
+ // Update the mapper factory to ignore unknown fields.
+ var factory = new MapperFactory().setDeserializationCustomFeatures(
+ Map.of(FAIL_ON_UNKNOWN_PROPERTIES, false)
+ );
+ JsonUtils.setCustomMapperFactory(factory);
+
+ // Attempt to deserialize the data to a Name object again. This time, it
+ // should not throw an exception, and the unknown field should be ignored.
+ Name javaObject = JsonUtils.getObjectReader().forType(Name.class)
+ .readValue(rawJSONString);
+ assertThat(javaObject).isEqualTo(expectedPOJO);
+ assertThat(javaObject.toString()).doesNotContain("stageName");
+ }
+
+ /**
+ * Tests a custom serialization setting on a mapper factory.
+ *
+ * @throws Exception If an unexpected error occurs.
+ */
+ @Test
+ public void testCustomSerialization() throws Exception
+ {
+ // A SCIM resource with a 'schemas' field set to a string instead of an
+ // array.
+ final String rawJSONString = "{"
+ + " \"schemas\": \"urn:ietf:params:scim:schemas:core:2.0:User\","
+ + " \"userName\": \"kendrick.lamar\""
+ + "}";
+
+ // Reformat the string in a standardized form.
+ final String expectedJSON = JsonUtils.getObjectReader()
+ .readTree(rawJSONString).toString();
+
+ UserResource user = new UserResource().setUserName("kendrick.lamar");
+
+ // Convert the user resource to a standardized JSON string and ensure the
+ // representation does not match 'expectedJSON'. By default, this member
+ // variable should be converted to an array.
+ String userJSON = JsonUtils.getObjectWriter().writeValueAsString(user);
+ assertThat(userJSON).isNotEqualTo(expectedJSON);
+ assertThat(userJSON).contains("[", "]");
+
+ // Update the object mapper to convert string values into single-valued
+ // arrays for array attributes.
+ MapperFactory factory = new MapperFactory().setSerializationCustomFeatures(
+ Map.of(WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED, true)
+ );
+ JsonUtils.setCustomMapperFactory(factory);
+
+ // Convert the resource to a string again. This time, the converted string
+ // should be equivalent to the expected JSON.
+ userJSON = JsonUtils.getObjectWriter().writeValueAsString(user);
+ assertThat(userJSON).isEqualTo(expectedJSON);
+ assertThat(userJSON).doesNotContain("[", "]");
+ }
+
+ /**
+ * Validates the behavior of setting a custom JSON parser feature.
+ *
+ *
+ * Note that the {@link com.unboundid.scim2.common.utils.Parser} class (used
+ * for processing string filters) leverages Jackson JSON Parsers, so this unit
+ * test validates that the behavior of the filter parser can be updated.
+ *
+ * @throws Exception If an unexpected error occurs.
+ */
+ @Test
+ public void testCustomJSONParser() throws Exception
+ {
+ // Ensure that single quotes are not permitted for filter values by default.
+ assertThatThrownBy(() -> Filter.fromString("userName eq 'kendrick'"))
+ .isInstanceOf(BadRequestException.class);
+
+ // Permit single quotes.
+ MapperFactory factory = new MapperFactory().setJsonParserCustomFeatures(
+ Map.of(ALLOW_SINGLE_QUOTES, true)
+ );
+ JsonUtils.setCustomMapperFactory(factory);
+
+ // The conversion should now be permitted.
+ Filter equalFilter = Filter.fromString("userName eq 'kendrick'");
+ assertThat(equalFilter.getFilterType()).isEqualTo(FilterType.EQUAL);
+ assertThat(equalFilter.getAttributePath())
+ .isNotNull()
+ .matches(path -> path.toString().equals("userName"));
+ assertThat(equalFilter.getComparisonValue())
+ .isEqualTo(TextNode.valueOf("kendrick"));
+ }
+
+ /**
+ * Tests support for overriding the {@link MapperFactory#createObjectMapper()}
+ * method.
+ *
+ *
+ * In some cases, a client application may require more specific
+ * customizations, such as setting custom serializers/deserializers for
+ * better integration with a SCIM service provider that provides
+ * non-standardized SCIM responses. For those cases, we should ensure that
+ * it's possible for client applications to extend the MapperFactory class and
+ * implement their own object mapper settings.
+ */
+ @Test
+ public void testOverrideMapperFactoryClass()
+ {
+ // Define a class that inherits from the SCIM SDK's MapperFactory and
+ // overrides the object mapper configuration to explicitly print all null
+ // values.
+ class CustomFactory extends MapperFactory
+ {
+ @NotNull
+ @Override
+ public ObjectMapper createObjectMapper()
+ {
+ ObjectMapper mapper = super.createObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.USE_DEFAULTS);
+ return mapper;
+ }
+ }
+
+ // Validate the SCIM SDK's default behavior.
+ final UserResource user = new UserResource().setUserName("kendrick.lamar");
+ assertThat(user.toString()).doesNotContain("null");
+
+ // Update the SCIM SDK's object mapper with the custom factory, which
+ // prints null values.
+ MapperFactory factory = new CustomFactory();
+ JsonUtils.setCustomMapperFactory(factory);
+
+ // Convert the resource to a string again and verify the change in behavior.
+ assertThat(user.toString()).contains("null");
+ }
+}