diff --git a/src/main/java/com/hubspot/jackson/datatype/protobuf/ProtobufEnumAsNumber.java b/src/main/java/com/hubspot/jackson/datatype/protobuf/ProtobufEnumAsNumber.java new file mode 100644 index 0000000..9d7ad97 --- /dev/null +++ b/src/main/java/com/hubspot/jackson/datatype/protobuf/ProtobufEnumAsNumber.java @@ -0,0 +1,104 @@ +package com.hubspot.jackson.datatype.protobuf; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +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.JsonMappingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.google.common.base.Preconditions; +import com.google.protobuf.ProtocolMessageEnum; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public final class ProtobufEnumAsNumber { + + private ProtobufEnumAsNumber() {} + + public static class Serializer extends StdSerializer { + + protected Serializer() { + super(ProtocolMessageEnum.class); + } + + @Override + public void serialize(ProtocolMessageEnum protocolMessageEnum, JsonGenerator jsonGenerator, SerializerProvider provider) throws IOException { + jsonGenerator.writeNumber(protocolMessageEnum.getNumber()); + } + } + + @SuppressFBWarnings({"SE_TRANSIENT_FIELD_NOT_RESTORED", "SE_NO_SERIALVERSIONID"}) + public static class Deserializer extends StdDeserializer implements ContextualDeserializer { + + private final transient Class enumClass; + private final transient Method forNumberMethod; + + protected Deserializer() { + this(null, null); + } + + protected Deserializer(Class enumClass, Method forNumberMethod) { + super(ProtocolMessageEnum.class); + this.enumClass = enumClass; + this.forNumberMethod = forNumberMethod; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext context, BeanProperty beanProperty) throws JsonMappingException { + // beanProperty is null when the type to deserialize is the top-level type or a generic type, not a type of a bean property + JavaType javaType = context.getContextualType(); + + if (javaType == null) { + javaType = beanProperty.getMember().getType(); + } + + Class enumClass = javaType.getRawClass(); + + if (enumClass == this.enumClass + && this.forNumberMethod != null) { + // short circuit if this instance is already correctly configured + return this; + } + + Method forNumberMethod; + + try { + forNumberMethod = enumClass.getMethod("forNumber", int.class); + } catch (NoSuchMethodException e) { + context.reportBadDefinition(javaType, "Could not find a static forNumber(int) method on this type"); + // the previous method should have thrown + throw new AssertionError(); + } + + return new Deserializer(enumClass, forNumberMethod); + } + + @Override + public ProtocolMessageEnum deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException { + int intValue = jsonParser.getIntValue(); + // Class enumClass = Preconditions.checkNotNull(javaType, "javaType").getRawClass(); + try { + return (ProtocolMessageEnum) Preconditions.checkNotNull(forNumberMethod, "forNumberMethod").invoke(null, intValue); + } catch (IllegalAccessException | InvocationTargetException e) { + context.reportWrongTokenException(ProtocolMessageEnum.class, JsonToken.VALUE_NUMBER_INT, wrongTokenMessage(context)); + // the previous method should have thrown + throw new AssertionError(); + } + } + } + + // TODO share this? + private static String wrongTokenMessage(DeserializationContext context) { + return "Can not deserialize instance of com.google.protobuf.ProtocolMessageEnum out of " + context.getParser().currentToken() + " token"; + } +} diff --git a/src/test/java/com/hubspot/jackson/datatype/protobuf/ProtobufEnumAsNumberTest.java b/src/test/java/com/hubspot/jackson/datatype/protobuf/ProtobufEnumAsNumberTest.java new file mode 100644 index 0000000..374a71b --- /dev/null +++ b/src/test/java/com/hubspot/jackson/datatype/protobuf/ProtobufEnumAsNumberTest.java @@ -0,0 +1,105 @@ +package com.hubspot.jackson.datatype.protobuf; + +import static com.hubspot.jackson.datatype.protobuf.util.ObjectMapperHelper.camelCase; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.hubspot.jackson.datatype.protobuf.util.TestProtobuf; +import com.hubspot.jackson.datatype.protobuf.util.TestProtobuf.Enum; + +public class ProtobufEnumAsNumberTest { + + @Test + public void itSerializesToEnumNumber() { + ObjectMapper mapper = camelCase(); + + for (Enum anEnum : Enum.values()) { + ObjectWithProtobufEnumField input = new ObjectWithProtobufEnumField(); + input.anEnum = anEnum; + + JsonNode node = mapper.valueToTree(input); + + assertThat(node.has("anEnum")).isTrue(); + assertThat(node.get("anEnum").isInt()).isTrue(); + assertThat(node.get("anEnum").intValue()).isEqualTo(anEnum.getNumber()); + } + } + + @Test + public void itDeserializesFromEnumNumber() throws JsonProcessingException { + ObjectMapper mapper = camelCase(); + + for (Enum anEnum : Enum.values()) { + ObjectWithProtobufEnumField input = new ObjectWithProtobufEnumField(); + input.anEnum = anEnum; + + JsonNode node = mapper.valueToTree(input); + + ObjectWithProtobufEnumField output = mapper.treeToValue(node, ObjectWithProtobufEnumField.class); + assertThat(output).isNotSameAs(input); + assertThat(output.anEnum).isEqualTo(input.anEnum); + } + } + + @Test + public void itFailsWhenSerializingNonProtobufEnumFields() { + ObjectMapper mapper = camelCase(); + + for (NonProtobufEnum anEnum : NonProtobufEnum.values()) { + ObjectWithVanillaEnumField input = new ObjectWithVanillaEnumField(); + input.anEnum = anEnum; + + try { + mapper.valueToTree(input); + fail("expected an exception to be thrown"); + } catch (Exception e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + } + } + + @Test + public void itFailsWhenDeserializingNonProtobufEnumFields() { + ObjectMapper mapper = camelCase(); + + for (NonProtobufEnum anEnum : NonProtobufEnum.values()) { + ObjectWithProtobufEnumField input = new ObjectWithProtobufEnumField(); + input.anEnum = Enum.valueOf(anEnum.name()); + + JsonNode node = mapper.valueToTree(input); + + try { + mapper.treeToValue(node, ObjectWithVanillaEnumField.class); + fail("expected an exception to be thrown"); + } catch (Exception e) { + assertThat(e).isInstanceOf(JsonMappingException.class); + } + } + } + + private static class ObjectWithProtobufEnumField { + + @JsonSerialize(using = ProtobufEnumAsNumber.Serializer.class) + @JsonDeserialize(using = ProtobufEnumAsNumber.Deserializer.class) + private TestProtobuf.Enum anEnum; + } + + private static class ObjectWithVanillaEnumField { + + @JsonSerialize(using = ProtobufEnumAsNumber.Serializer.class) + @JsonDeserialize(using = ProtobufEnumAsNumber.Deserializer.class) + private NonProtobufEnum anEnum; + } + + private enum NonProtobufEnum { + ONE, TWO + } +}