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: + * + * + * 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"); + } +}