forked from opensearch-project/data-prepper
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add custom enum deserializer to improve error messaging, improve byte…
… count error mesages (opensearch-project#5076) Signed-off-by: Taylor Gray <[email protected]>
- Loading branch information
1 parent
f10867f
commit e9bffee
Showing
9 changed files
with
355 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
111 changes: 111 additions & 0 deletions
111
...ine-parser/src/main/java/org/opensearch/dataprepper/pipeline/parser/EnumDeserializer.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
package org.opensearch.dataprepper.pipeline.parser; | ||
|
||
import com.fasterxml.jackson.annotation.JsonCreator; | ||
import com.fasterxml.jackson.annotation.JsonValue; | ||
import com.fasterxml.jackson.core.JsonParser; | ||
import com.fasterxml.jackson.databind.BeanProperty; | ||
import com.fasterxml.jackson.databind.DeserializationContext; | ||
import com.fasterxml.jackson.databind.JavaType; | ||
import com.fasterxml.jackson.databind.JsonDeserializer; | ||
import com.fasterxml.jackson.databind.JsonNode; | ||
import com.fasterxml.jackson.databind.deser.ContextualDeserializer; | ||
|
||
import java.io.IOException; | ||
import java.lang.reflect.InvocationTargetException; | ||
import java.lang.reflect.Method; | ||
import java.util.Arrays; | ||
import java.util.List; | ||
import java.util.Optional; | ||
import java.util.stream.Collectors; | ||
|
||
|
||
/** | ||
* This deserializer is used for any Enum classes when converting the pipeline configuration file into the plugin model classes | ||
* @since 2.11 | ||
*/ | ||
public class EnumDeserializer extends JsonDeserializer<Enum<?>> implements ContextualDeserializer { | ||
|
||
static final String INVALID_ENUM_VALUE_ERROR_FORMAT = "Invalid value \"%s\". Valid options include %s."; | ||
|
||
private Class<?> enumClass; | ||
|
||
public EnumDeserializer() {} | ||
|
||
public EnumDeserializer(final Class<?> enumClass) { | ||
if (!enumClass.isEnum()) { | ||
throw new IllegalArgumentException("The provided class is not an enum: " + enumClass.getName()); | ||
} | ||
|
||
this.enumClass = enumClass; | ||
} | ||
@Override | ||
public Enum<?> deserialize(final JsonParser p, final DeserializationContext ctxt) throws IOException { | ||
final JsonNode node = p.getCodec().readTree(p); | ||
final String enumValue = node.asText(); | ||
|
||
final Optional<Method> jsonCreator = findJsonCreatorMethod(); | ||
|
||
try { | ||
jsonCreator.ifPresent(method -> method.setAccessible(true)); | ||
|
||
for (Object enumConstant : enumClass.getEnumConstants()) { | ||
try { | ||
if (jsonCreator.isPresent() && enumConstant.equals(jsonCreator.get().invoke(null, enumValue))) { | ||
return (Enum<?>) enumConstant; | ||
} else if (jsonCreator.isEmpty() && enumConstant.toString().toLowerCase().equals(enumValue)) { | ||
return (Enum<?>) enumConstant; | ||
} | ||
} catch (IllegalAccessException | InvocationTargetException e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
} finally { | ||
jsonCreator.ifPresent(method -> method.setAccessible(false)); | ||
} | ||
|
||
|
||
|
||
final Optional<Method> jsonValueMethod = findJsonValueMethodForClass(); | ||
final List<Object> listOfEnums = jsonValueMethod.map(method -> Arrays.stream(enumClass.getEnumConstants()) | ||
.map(valueEnum -> { | ||
try { | ||
return method.invoke(valueEnum); | ||
} catch (IllegalAccessException | InvocationTargetException e) { | ||
throw new RuntimeException(e); | ||
} | ||
}) | ||
.collect(Collectors.toList())).orElseGet(() -> Arrays.stream(enumClass.getEnumConstants()) | ||
.map(valueEnum -> valueEnum.toString().toLowerCase()) | ||
.collect(Collectors.toList())); | ||
|
||
throw new IllegalArgumentException(String.format(INVALID_ENUM_VALUE_ERROR_FORMAT, enumValue, listOfEnums)); | ||
} | ||
|
||
@Override | ||
public JsonDeserializer<?> createContextual(final DeserializationContext ctxt, final BeanProperty property) { | ||
final JavaType javaType = property.getType(); | ||
final Class<?> rawClass = javaType.getRawClass(); | ||
|
||
return new EnumDeserializer(rawClass); | ||
} | ||
|
||
private Optional<Method> findJsonValueMethodForClass() { | ||
for (final Method method : enumClass.getDeclaredMethods()) { | ||
if (method.isAnnotationPresent(JsonValue.class)) { | ||
return Optional.of(method); | ||
} | ||
} | ||
|
||
return Optional.empty(); | ||
} | ||
|
||
private Optional<Method> findJsonCreatorMethod() { | ||
for (final Method method : enumClass.getDeclaredMethods()) { | ||
if (method.isAnnotationPresent(JsonCreator.class)) { | ||
return Optional.of(method); | ||
} | ||
} | ||
|
||
return Optional.empty(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
174 changes: 174 additions & 0 deletions
174
...parser/src/test/java/org/opensearch/dataprepper/pipeline/parser/EnumDeserializerTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
package org.opensearch.dataprepper.pipeline.parser; | ||
|
||
import com.fasterxml.jackson.annotation.JsonCreator; | ||
import com.fasterxml.jackson.annotation.JsonValue; | ||
import com.fasterxml.jackson.core.JsonParser; | ||
import com.fasterxml.jackson.databind.BeanProperty; | ||
import com.fasterxml.jackson.databind.DeserializationContext; | ||
import com.fasterxml.jackson.databind.JavaType; | ||
import com.fasterxml.jackson.databind.JsonDeserializer; | ||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
import com.fasterxml.jackson.databind.node.TextNode; | ||
import org.hamcrest.Matchers; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
import org.junit.jupiter.params.ParameterizedTest; | ||
import org.junit.jupiter.params.provider.EnumSource; | ||
import org.opensearch.dataprepper.model.event.HandleFailedEventsOption; | ||
|
||
import java.io.IOException; | ||
import java.time.Duration; | ||
import java.util.Arrays; | ||
import java.util.Map; | ||
import java.util.UUID; | ||
import java.util.function.Function; | ||
import java.util.stream.Collectors; | ||
|
||
import static org.hamcrest.MatcherAssert.assertThat; | ||
import static org.hamcrest.Matchers.containsString; | ||
import static org.hamcrest.Matchers.equalTo; | ||
import static org.hamcrest.Matchers.instanceOf; | ||
import static org.hamcrest.Matchers.notNullValue; | ||
import static org.junit.jupiter.api.Assertions.assertThrows; | ||
import static org.mockito.Mockito.mock; | ||
import static org.mockito.Mockito.when; | ||
|
||
public class EnumDeserializerTest { | ||
|
||
private ObjectMapper objectMapper; | ||
|
||
@BeforeEach | ||
void setup() { | ||
objectMapper = mock(ObjectMapper.class); | ||
} | ||
|
||
private EnumDeserializer createObjectUnderTest(final Class<?> enumClass) { | ||
return new EnumDeserializer(enumClass); | ||
} | ||
|
||
@Test | ||
void non_enum_class_throws_IllegalArgumentException() { | ||
assertThrows(IllegalArgumentException.class, () -> new EnumDeserializer(Duration.class)); | ||
} | ||
|
||
@ParameterizedTest | ||
@EnumSource(TestEnum.class) | ||
void enum_class_with_json_creator_annotation_returns_expected_enum_constant(final TestEnum testEnumOption) throws IOException { | ||
final EnumDeserializer objectUnderTest = createObjectUnderTest(TestEnum.class); | ||
final JsonParser jsonParser = mock(JsonParser.class); | ||
final DeserializationContext deserializationContext = mock(DeserializationContext.class); | ||
when(jsonParser.getCodec()).thenReturn(objectMapper); | ||
|
||
when(objectMapper.readTree(jsonParser)).thenReturn(new TextNode(testEnumOption.toString())); | ||
|
||
Enum<?> result = objectUnderTest.deserialize(jsonParser, deserializationContext); | ||
|
||
assertThat(result, equalTo(testEnumOption)); | ||
} | ||
|
||
@ParameterizedTest | ||
@EnumSource(TestEnumWithoutJsonCreator.class) | ||
void enum_class_without_json_creator_annotation_returns_expected_enum_constant(final TestEnumWithoutJsonCreator enumWithoutJsonCreator) throws IOException { | ||
final EnumDeserializer objectUnderTest = createObjectUnderTest(TestEnumWithoutJsonCreator.class); | ||
final JsonParser jsonParser = mock(JsonParser.class); | ||
final DeserializationContext deserializationContext = mock(DeserializationContext.class); | ||
when(jsonParser.getCodec()).thenReturn(objectMapper); | ||
|
||
when(objectMapper.readTree(jsonParser)).thenReturn(new TextNode(enumWithoutJsonCreator.toString())); | ||
|
||
Enum<?> result = objectUnderTest.deserialize(jsonParser, deserializationContext); | ||
|
||
assertThat(result, equalTo(enumWithoutJsonCreator)); | ||
} | ||
|
||
@Test | ||
void enum_class_with_invalid_value_and_jsonValue_annotation_throws_IllegalArgumentException() throws IOException { | ||
final EnumDeserializer objectUnderTest = createObjectUnderTest(TestEnum.class); | ||
final JsonParser jsonParser = mock(JsonParser.class); | ||
final DeserializationContext deserializationContext = mock(DeserializationContext.class); | ||
when(jsonParser.getCodec()).thenReturn(objectMapper); | ||
|
||
final String invalidValue = UUID.randomUUID().toString(); | ||
when(objectMapper.readTree(jsonParser)).thenReturn(new TextNode(invalidValue)); | ||
|
||
final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> | ||
objectUnderTest.deserialize(jsonParser, deserializationContext)); | ||
|
||
assertThat(exception, notNullValue()); | ||
final String expectedErrorMessage = "Invalid value \"" + invalidValue + "\". Valid options include"; | ||
assertThat(exception.getMessage(), Matchers.startsWith(expectedErrorMessage)); | ||
assertThat(exception.getMessage(), containsString("[test_display_one, test_display_two, test_display_three]")); | ||
} | ||
|
||
@Test | ||
void enum_class_with_invalid_value_and_no_jsonValue_annotation_throws_IllegalArgumentException() throws IOException { | ||
final EnumDeserializer objectUnderTest = createObjectUnderTest(TestEnumWithoutJsonCreator.class); | ||
final JsonParser jsonParser = mock(JsonParser.class); | ||
final DeserializationContext deserializationContext = mock(DeserializationContext.class); | ||
when(jsonParser.getCodec()).thenReturn(objectMapper); | ||
|
||
final String invalidValue = UUID.randomUUID().toString(); | ||
when(objectMapper.readTree(jsonParser)).thenReturn(new TextNode(invalidValue)); | ||
|
||
final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> | ||
objectUnderTest.deserialize(jsonParser, deserializationContext)); | ||
|
||
assertThat(exception, notNullValue()); | ||
final String expectedErrorMessage = "Invalid value \"" + invalidValue + "\". Valid options include"; | ||
assertThat(exception.getMessage(), Matchers.startsWith(expectedErrorMessage)); | ||
|
||
} | ||
|
||
@Test | ||
void create_contextual_returns_expected_enum_deserializer() { | ||
final DeserializationContext context = mock(DeserializationContext.class); | ||
final BeanProperty property = mock(BeanProperty.class); | ||
|
||
final ObjectMapper mapper = new ObjectMapper(); | ||
final JavaType javaType = mapper.constructType(HandleFailedEventsOption.class); | ||
when(property.getType()).thenReturn(javaType); | ||
|
||
final EnumDeserializer objectUnderTest = new EnumDeserializer(); | ||
JsonDeserializer<?> result = objectUnderTest.createContextual(context, property); | ||
|
||
assertThat(result, instanceOf(EnumDeserializer.class)); | ||
} | ||
|
||
private enum TestEnum { | ||
TEST_ONE("test_display_one"), | ||
TEST_TWO("test_display_two"), | ||
TEST_THREE("test_display_three"); | ||
private static final Map<String, TestEnum> NAMES_MAP = Arrays.stream(TestEnum.values()) | ||
.collect(Collectors.toMap(TestEnum::toString, Function.identity())); | ||
private final String name; | ||
TestEnum(final String name) { | ||
this.name = name; | ||
} | ||
|
||
@JsonValue | ||
public String toString() { | ||
return this.name; | ||
} | ||
@JsonCreator | ||
static TestEnum fromOptionValue(final String option) { | ||
return NAMES_MAP.get(option); | ||
} | ||
} | ||
|
||
private enum TestEnumWithoutJsonCreator { | ||
TEST("test"); | ||
private static final Map<String, TestEnumWithoutJsonCreator> NAMES_MAP = Arrays.stream(TestEnumWithoutJsonCreator.values()) | ||
.collect(Collectors.toMap(TestEnumWithoutJsonCreator::toString, Function.identity())); | ||
private final String name; | ||
TestEnumWithoutJsonCreator(final String name) { | ||
this.name = name; | ||
} | ||
public String toString() { | ||
return this.name; | ||
} | ||
|
||
static TestEnumWithoutJsonCreator fromOptionValue(final String option) { | ||
return NAMES_MAP.get(option); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.