diff --git a/spring-geode/src/main/java/org/springframework/geode/data/json/converter/support/JacksonJsonToObjectConverter.java b/spring-geode/src/main/java/org/springframework/geode/data/json/converter/support/JacksonJsonToObjectConverter.java new file mode 100644 index 00000000..6dff722a --- /dev/null +++ b/spring-geode/src/main/java/org/springframework/geode/data/json/converter/support/JacksonJsonToObjectConverter.java @@ -0,0 +1,144 @@ +/* + * Copyright 2020 the original author or authors. + * + * 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 + * + * https://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. + */ +package org.springframework.geode.data.json.converter.support; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import com.fasterxml.jackson.databind.node.POJONode; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.mapping.MappingException; +import org.springframework.geode.data.json.converter.JsonToObjectConverter; +import org.springframework.geode.pdx.PdxInstanceWrapper; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * {@link JsonToObjectConverter} implementation using Jackson to convert {@link String JSON} + * to an {@link Object} (POJO). + * + * @author John Blum + * @see com.fasterxml.jackson.databind.JsonNode + * @see com.fasterxml.jackson.databind.ObjectMapper + * @see com.fasterxml.jackson.databind.node.POJONode + * @see org.springframework.core.convert.converter.Converter + * @see org.springframework.geode.data.json.converter.JsonToObjectConverter + * @since 1.3.0 + */ +public class JacksonJsonToObjectConverter implements JsonToObjectConverter { + + protected static final String AT_TYPE_FIELD_NAME = PdxInstanceWrapper.AT_TYPE_FIELD_NAME; + + private ObjectMapper objectMapper = newObjectMapper(); + + /** + * Constructs a new Jackson {@link ObjectMapper} to convert {@link String JSON} into an {@link Object} (POJO). + * + * @return a new Jackson {@link ObjectMapper}; never {@literal null}. + * @see com.fasterxml.jackson.databind.ObjectMapper + */ + // TODO configure via an SPI + private @NonNull ObjectMapper newObjectMapper() { + + return new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true) + .findAndRegisterModules(); + } + + /** + * Returns a reference to the configured Jackson {@link ObjectMapper} used by this {@link Converter} + * to convert {@link String JSON} into an {@link Object} (POJO). + * + * @return a reference to the configured Jackson {@link ObjectMapper}. + */ + protected @NonNull ObjectMapper getObjectMapper() { + return this.objectMapper; + } + + /** + * Converts from {@link String JSON} to an {@link Object} (POJO) using Jackson's {@link ObjectMapper}. + * + * @param json {@link String} containing {@literal JSON} to convert. + * @return an {@link Object} (POJO) converted from the given {@link String JSON}. + * @see #getObjectMapper() + */ + @Override + public @Nullable Object convert(@Nullable String json) { + + if (StringUtils.hasText(json)) { + + String objectTypeName = null; + + try { + + ObjectMapper objectMapper = getObjectMapper(); + + JsonNode jsonNode = objectMapper.readTree(json); + + if (isPojo(jsonNode)) { + return ((POJONode) jsonNode).getPojo(); + } + else { + + Assert.state(jsonNode.isObject(), () -> String.format("The JSON [%s] must be an object", json)); + + Assert.state(jsonNode.has(AT_TYPE_FIELD_NAME), + () -> String.format("The JSON object [%1$s] must have an '%2$s' metadata field", + json, AT_TYPE_FIELD_NAME)); + + objectTypeName = jsonNode.get(AT_TYPE_FIELD_NAME).asText(); + + Class objectType = + ClassUtils.forName(objectTypeName, Thread.currentThread().getContextClassLoader()); + + return objectMapper.readValue(json, objectType); + } + } + catch (ClassNotFoundException cause) { + throw new MappingException(String.format("Failed to map JSON [%1$s] to an Object of type [%2$s]", + json, objectTypeName), cause); + } + catch (JsonProcessingException cause) { + throw new DataRetrievalFailureException(String.format("Failed to read JSON [%s]", json), cause); + } + } + + return null; + } + + /** + * Null-safe method to determine whether the given {@link JsonNode} represents a {@link Object POJO}. + * + * @param jsonNode {@link JsonNode} to evaluate. + * @return a boolean value indicating whether the given {@link JsonNode} represents a {@link Object POJO}. + * @see com.fasterxml.jackson.databind.JsonNode + */ + boolean isPojo(@Nullable JsonNode jsonNode) { + + return jsonNode != null + && (jsonNode instanceof POJONode || jsonNode.isPojo() || JsonNodeType.POJO.equals(jsonNode.getNodeType())); + } +} diff --git a/spring-geode/src/test/java/org/springframework/geode/data/json/converter/support/JacksonJsonToObjectConverterUnitTests.java b/spring-geode/src/test/java/org/springframework/geode/data/json/converter/support/JacksonJsonToObjectConverterUnitTests.java new file mode 100644 index 00000000..26f9c3b2 --- /dev/null +++ b/spring-geode/src/test/java/org/springframework/geode/data/json/converter/support/JacksonJsonToObjectConverterUnitTests.java @@ -0,0 +1,314 @@ +/* + * Copyright 2020 the original author or authors. + * + * 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 + * + * https://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. + */ +package org.springframework.geode.data.json.converter.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import com.fasterxml.jackson.databind.node.POJONode; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.mapping.MappingException; + +import example.app.crm.model.Customer; + +/** + * Unit Tests for {@link JacksonJsonToObjectConverter}. + * + * @author John Blum + * @see org.junit.Test + * @see org.mockito.Mockito + * @see org.mockito.Spy + * @see org.mockito.junit.MockitoJUnitRunner + * @see org.springframework.geode.data.json.converter.support.JacksonJsonToObjectConverter + * @see com.fasterxml.jackson.databind.ObjectMapper + * @see com.fasterxml.jackson.databind.JsonNode + * @since 1.3.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class JacksonJsonToObjectConverterUnitTests { + + @Spy + JacksonJsonToObjectConverter converter; + + @Test + public void objectMapperConfigurationIsCorrect() { + + ObjectMapper objectMapper = this.converter.getObjectMapper(); + + assertThat(objectMapper).isNotNull(); + assertThat(objectMapper.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)).isTrue(); + assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES)).isFalse(); + assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + } + + @Test + public void convertingInvalidJsonReturnsNull() { + assertThat(this.converter.convert((String) null)).isNull(); + assertThat(this.converter.convert("")).isNull(); + assertThat(this.converter.convert(" ")).isNull(); + } + + @Test + public void convertPojoReturnsPojo() throws JsonProcessingException { + + String json = "{\"id\":1,\"name\":\"Jon Doe\"}"; + + POJONode mockJsonNode = mock(POJONode.class); + + ObjectMapper mockObjectMapper = mock(ObjectMapper.class); + + Customer jonDoe = Customer.newCustomer(1L, "Jon Doe"); + + doReturn(mockObjectMapper).when(this.converter).getObjectMapper(); + doReturn(mockJsonNode).when(mockObjectMapper).readTree(eq(json)); + doReturn(jonDoe).when(mockJsonNode).getPojo(); + + assertThat(this.converter.convert(json)).isEqualTo(jonDoe); + + verify(this.converter, times(1)).getObjectMapper(); + verify(mockObjectMapper, times(1)).readTree(eq(json)); + verify(mockJsonNode, times(1)).getPojo(); + } + + @Test + public void convertJsonObjectReturnsObject() { + + String json = String.format("{\"@type\":\"%s\",\"id\":2,\"name\":\"Jane Doe\"}", Customer.class.getName()); + + Object value = this.converter.convert(json); + + assertThat(value).isInstanceOf(Customer.class); + + Customer janeDoe = (Customer) value; + + assertThat(janeDoe.getId()).isEqualTo(2L); + assertThat(janeDoe.getName()).isEqualTo("Jane Doe"); + } + + @Test(expected = IllegalStateException.class) + public void convertJsonArrayThrowsIllegalStateException() throws JsonProcessingException { + + String json = "[]"; + + ObjectMapper mockObjectMapper = mock(ObjectMapper.class); + + JsonNode mockJsonNode = mock(JsonNode.class); + + doReturn(mockObjectMapper).when(this.converter).getObjectMapper(); + doReturn(mockJsonNode).when(mockObjectMapper).readTree(eq(json)); + doReturn(false).when(mockJsonNode).isObject(); + + try { + this.converter.convert(json); + } + catch (IllegalStateException expected) { + + assertThat(expected).hasMessage("The JSON [%s] must be an object", json); + assertThat(expected).hasNoCause(); + + throw expected; + } + finally { + verify(this.converter, times(1)).getObjectMapper(); + verify(mockObjectMapper, times(1)).readTree(eq(json)); + verify(mockJsonNode, times(1)).isObject(); + verify(mockJsonNode, never()).has(anyString()); + verify(mockJsonNode, never()).get(anyString()); + verifyNoMoreInteractions(mockObjectMapper); + } + } + + @Test(expected = IllegalStateException.class) + public void convertJsonObjectWithNoTypeMetadataThrowsIllegalStateException() throws JsonProcessingException { + + String json = "{\"id\":3,\"name\":\"Pie Doe\"}"; + + ObjectMapper mockObjectMapper = mock(ObjectMapper.class); + + JsonNode mockJsonNode = mock(JsonNode.class); + + doReturn(mockObjectMapper).when(this.converter).getObjectMapper(); + doReturn(mockJsonNode).when(mockObjectMapper).readTree(eq(json)); + doReturn(true).when(mockJsonNode).isObject(); + doReturn(false).when(mockJsonNode).has(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME); + + try { + this.converter.convert(json); + } + catch (IllegalStateException expected) { + + assertThat(expected).hasMessage("The JSON object [%s] must have an '@type' metadata field", json); + assertThat(expected).hasNoCause(); + + throw expected; + } + finally { + verify(this.converter, times(1)).getObjectMapper(); + verify(mockObjectMapper, times(1)).readTree(eq(json)); + verify(mockJsonNode, times(1)).isObject(); + verify(mockJsonNode, times(1)).has(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + verify(mockJsonNode, never()).get(anyString()); + verifyNoMoreInteractions(mockObjectMapper); + } + } + + @Test(expected = MappingException.class) + public void convertJsonHandlesClassNotFoundException() throws JsonProcessingException { + + String json = "{\"@type\":\"non.existing.class.Type\",\"id\":4,\"name\":\"Play Doe\"}"; + + ObjectMapper mockObjectMapper = mock(ObjectMapper.class); + + JsonNode mockJsonNode = mock(JsonNode.class); + JsonNode mockAtTypeJsonNode = mock(JsonNode.class); + + doReturn(mockObjectMapper).when(this.converter).getObjectMapper(); + doReturn(mockJsonNode).when(mockObjectMapper).readTree(eq(json)); + doReturn(true).when(mockJsonNode).isObject(); + doReturn(true).when(mockJsonNode).has(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME); + doReturn(mockAtTypeJsonNode).when(mockJsonNode).get(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + doReturn("non.existing.class.Type").when(mockAtTypeJsonNode).asText(); + + try { + this.converter.convert(json); + } + catch (MappingException expected) { + + assertThat(expected) + .hasMessage("Failed to map JSON [%s] to an Object of type [non.existing.class.Type]", json); + + assertThat(expected).hasCauseInstanceOf(ClassNotFoundException.class); + assertThat(expected.getCause()).hasMessageContaining("non.existing.class.Type"); + assertThat(expected.getCause()).hasNoCause(); + + throw expected; + } + finally { + verify(this.converter, times(1)).getObjectMapper(); + verify(mockObjectMapper, times(1)).readTree(eq(json)); + verify(mockJsonNode, times(1)).isObject(); + verify(mockJsonNode, times(1)).has(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + verify(mockJsonNode, times(1)).get(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + verify(mockAtTypeJsonNode, times(1)).asText(); + verifyNoMoreInteractions(mockObjectMapper); + } + } + + @Test(expected = DataRetrievalFailureException.class) + public void convertJsonHandlesJsonProcessingException() throws JsonProcessingException { + + String json = String.format("{\"@type\":\"%s\",\"id\":5,\"name\":\"Poe Doe\"}", Customer.class.getName()); + + ObjectMapper mockObjectMapper = mock(ObjectMapper.class); + + JsonNode mockJsonNode = mock(JsonNode.class); + JsonNode mockAtTypeJsonNode = mock(JsonNode.class); + + doReturn(mockObjectMapper).when(this.converter).getObjectMapper(); + doReturn(mockJsonNode).when(mockObjectMapper).readTree(eq(json)); + doReturn(true).when(mockJsonNode).isObject(); + doReturn(true).when(mockJsonNode).has(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME); + doReturn(mockAtTypeJsonNode).when(mockJsonNode).get(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + doReturn(Customer.class.getName()).when(mockAtTypeJsonNode).asText(); + doThrow(new JsonParseException(null, "TEST")).when(mockObjectMapper).readValue(eq(json), eq(Customer.class)); + + try { + this.converter.convert(json); + } + catch (DataRetrievalFailureException expected) { + + assertThat(expected).hasMessageContaining("Failed to read JSON [%s]", json); + assertThat(expected).hasCauseInstanceOf(JsonParseException.class); + assertThat(expected.getCause()).hasMessage("TEST"); + assertThat(expected.getCause()).hasNoCause(); + + throw expected; + } + finally { + verify(this.converter, times(1)).getObjectMapper(); + verify(mockObjectMapper, times(1)).readTree(eq(json)); + verify(mockJsonNode, times(1)).isObject(); + verify(mockJsonNode, times(1)).has(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + verify(mockJsonNode, times(1)).get(eq(JacksonJsonToObjectConverter.AT_TYPE_FIELD_NAME)); + verify(mockAtTypeJsonNode, times(1)).asText(); + verify(mockObjectMapper, times(1)).readValue(eq(json), eq(Customer.class)); + verifyNoMoreInteractions(mockObjectMapper); + } + } + + @Test + public void isPojoWithPojoNode() { + + POJONode mockPojoNode = mock(POJONode.class); + + assertThat(this.converter.isPojo(mockPojoNode)).isTrue(); + + verifyNoInteractions(mockPojoNode); + } + + @Test + public void isPojoWhenJsonNodeGetNodeTypeReturnsPojo() { + + JsonNode mockJsonNode = mock(JsonNode.class); + + doReturn(JsonNodeType.POJO).when(mockJsonNode).getNodeType(); + + assertThat(this.converter.isPojo(mockJsonNode)).isTrue(); + + verify(mockJsonNode, times(1)).getNodeType(); + verifyNoMoreInteractions(mockJsonNode); + } + + @Test + public void isNotPojoWhenJsonNodeGetNodeTypeReturnsBinary() { + + JsonNode mockJsonNode = mock(JsonNode.class); + + doReturn(JsonNodeType.BINARY).when(mockJsonNode).getNodeType(); + + assertThat(this.converter.isPojo(mockJsonNode)).isFalse(); + + verify(mockJsonNode, times(2)).getNodeType(); + verifyNoMoreInteractions(mockJsonNode); + } + + @Test + public void isNotPojoWithNullIsNullSafe() { + assertThat(this.converter.isPojo(null)).isFalse(); + } +}