diff --git a/conjure-java-client-verifier/src/test/resources/ignored-test-cases.jersey.yml b/conjure-java-client-verifier/src/test/resources/ignored-test-cases.jersey.yml index 032f2e2c6..ceca02cb3 100644 --- a/conjure-java-client-verifier/src/test/resources/ignored-test-cases.jersey.yml +++ b/conjure-java-client-verifier/src/test/resources/ignored-test-cases.jersey.yml @@ -99,8 +99,6 @@ client: - 'null' receiveMapEnumExampleAlias: - 'null' - receiveSafeLongExample: #allowed long coercion applies to safelong - - '{"value":"12"}' singleHeaderService: {} diff --git a/conjure-java-client-verifier/src/test/resources/ignored-test-cases.retrofit.yml b/conjure-java-client-verifier/src/test/resources/ignored-test-cases.retrofit.yml index 75c18394d..9c3574822 100644 --- a/conjure-java-client-verifier/src/test/resources/ignored-test-cases.retrofit.yml +++ b/conjure-java-client-verifier/src/test/resources/ignored-test-cases.retrofit.yml @@ -136,8 +136,6 @@ client: - 'null' receiveMapEnumExampleAlias: - 'null' - receiveSafeLongExample: #allowed long coercion applies to safelong - - '{"value":"12"}' singleHeaderService: {} diff --git a/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/LenientLongModule.java b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/LenientLongModule.java new file mode 100644 index 000000000..6c08ea92e --- /dev/null +++ b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/LenientLongModule.java @@ -0,0 +1,94 @@ +/* + * (c) Copyright 2019 Palantir Technologies Inc. All rights reserved. + * + * 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 + * + * http://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 com.palantir.conjure.java.serialization; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jdk8.OptionalLongDeserializer; +import com.palantir.logsafe.exceptions.SafeIoException; +import java.io.IOException; +import java.util.OptionalLong; + +/** + * Provides support for the {@link Long} deserialization from JSON string and numeric values regardless of + * + *
MapperFeature.ALLOW_COERCION_OF_SCALARS
+ * + * configuration. + */ +final class LenientLongModule extends SimpleModule { + + LenientLongModule() { + super("lenient long"); + // Register to both Long.TYPE and Long.class + this.addDeserializer(long.class, new LongAsStringDeserializer()) + .addDeserializer(Long.class, new LongAsStringDeserializer()) + .addDeserializer(OptionalLong.class, new OptionalLongAsStringDeserializer()); + } + + private static final class LongAsStringDeserializer extends StdDeserializer { + + private LongAsStringDeserializer() { + super(Long.TYPE); + } + + @Override + public Long deserialize(JsonParser jsonParser, DeserializationContext _ctxt) throws IOException { + switch (jsonParser.currentToken()) { + case VALUE_NUMBER_INT: + return jsonParser.getLongValue(); + case VALUE_STRING: + return parseLong(jsonParser); + case VALUE_NULL: + return null; + default: + throw new SafeIoException("Expected a long value"); + } + } + + @Override + public boolean isCachable() { + return true; + } + + private static Long parseLong(JsonParser jsonParser) throws IOException { + String value = jsonParser.getValueAsString(); + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + InvalidFormatException failure = + new InvalidFormatException(jsonParser, "not a valid long value", value, long.class); + failure.initCause(e); + throw failure; + } + } + } + + private static final class OptionalLongAsStringDeserializer extends OptionalLongDeserializer { + + private OptionalLongAsStringDeserializer() {} + + @Override + protected CoercionAction _checkFromStringCoercion(DeserializationContext _ctxt, String _value) { + return CoercionAction.TryConvert; + } + } +} diff --git a/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java index 4161030f5..916647ee0 100644 --- a/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java +++ b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java @@ -20,15 +20,12 @@ import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.cfg.CoercionAction; -import com.fasterxml.jackson.databind.cfg.CoercionInputShape; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; import com.fasterxml.jackson.dataformat.smile.SmileFactory; import com.fasterxml.jackson.datatype.guava.GuavaModule; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.module.afterburner.AfterburnerModule; -import java.util.OptionalLong; public final class ObjectMappers { @@ -131,14 +128,12 @@ public static ObjectMapper newSmileServerObjectMapper() { * */ public static ObjectMapper withDefaultModules(ObjectMapper mapper) { - allowStringCoercion(mapper, long.class); - allowStringCoercion(mapper, Long.class); - allowStringCoercion(mapper, OptionalLong.class); return mapper.registerModule(new GuavaModule()) .registerModule(new ShimJdk7Module()) .registerModule(new Jdk8Module().configureAbsentsAsNulls(true)) .registerModule(new AfterburnerModule()) .registerModule(new JavaTimeModule()) + .registerModule(new LenientLongModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) @@ -148,10 +143,4 @@ public static ObjectMapper withDefaultModules(ObjectMapper mapper) { .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); } - - private static void allowStringCoercion(ObjectMapper mapper, Class clazz) { - mapper.coercionConfigFor(clazz) - .setAcceptBlankAsEmpty(false) - .setCoercion(CoercionInputShape.String, CoercionAction.TryConvert); - } } diff --git a/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java b/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java index cf01ec487..2a4d8bc0b 100644 --- a/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java +++ b/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java @@ -19,12 +19,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.exc.InputCoercionException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.palantir.logsafe.Preconditions; import java.io.File; import java.io.IOException; import java.math.BigInteger; @@ -38,6 +40,7 @@ import java.time.ZonedDateTime; import java.util.Collections; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; import java.util.OptionalLong; @@ -128,11 +131,115 @@ public void testLongTypeDeserializationFromString() throws IOException { assertThat(MAPPER.readValue("\"1\"", Long.TYPE)).isEqualTo(1L); } + @Test + public void testLongBeanTypeDeserializationFromString() throws IOException { + assertThat(MAPPER.readValue("{\"value\":\"1\"}", LongBean.class)).isEqualTo(new LongBean(1L)); + } + + @Test + public void testLongBeanTypeDeserializationFromNumber() throws IOException { + assertThat(MAPPER.readValue("{\"value\":\"1\"}", LongBean.class)).isEqualTo(new LongBean(1L)); + } + + static final class LongBean { + @JsonProperty + private long value; + + LongBean() {} + + LongBean(long value) { + setValue(value); + } + + public long getValue() { + return value; + } + + public void setValue(long value) { + this.value = value; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + LongBean that = (LongBean) other; + return value == that.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public String toString() { + return "LongBean{value=" + value + '}'; + } + } + @Test public void testOptionalLongTypeDeserializationFromString() throws IOException { assertThat(MAPPER.readValue("\"1\"", OptionalLong.class)).hasValue(1L); } + @Test + public void testOptionalLongBeanTypeDeserializationFromString() throws IOException { + assertThat(MAPPER.readValue("{\"value\":\"1\"}", OptionalLongBean.class)) + .isEqualTo(new OptionalLongBean(OptionalLong.of(1L))); + } + + @Test + public void testOptionalLongBeanTypeDeserializationFromNumber() throws IOException { + assertThat(MAPPER.readValue("{\"value\":1}", OptionalLongBean.class)) + .isEqualTo(new OptionalLongBean(OptionalLong.of(1L))); + } + + static final class OptionalLongBean { + @JsonProperty + private OptionalLong value = OptionalLong.empty(); + + OptionalLongBean() {} + + OptionalLongBean(OptionalLong value) { + setValue(value); + } + + public OptionalLong getValue() { + return value; + } + + public void setValue(OptionalLong value) { + this.value = Preconditions.checkNotNull(value, "value"); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + OptionalLongBean that = (OptionalLongBean) other; + return value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public String toString() { + return "OptionalLongBean{value=" + value + '}'; + } + } + @Test public void testLongDeserializationFromJsonNumber() throws IOException { assertThat(MAPPER.readValue("1", Long.class)).isEqualTo(1L);