diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 04aeb40e14..61a274881c 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -115,6 +115,16 @@ Fouad Almalki (@Eng-Fouad) * Contributed fix for 5442: Make `JsonMapper/ObjectMapper` fully proxyable by CGLIB [3.1.0] +Christopher Currie (@christophercurrie) + +* Requested #221: Support alternate radixes when writing numeric values as strings + [3.1.0] + +Davyd Fridman (@tiger9800) + +* Implemented #221: Support alternate radixes when writing numeric values as strings + [3.1.0] + Konstantin Labun (@kulabun) * Reported #650: `@JsonUnwrapped` prevents checks for `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES` diff --git a/release-notes/VERSION b/release-notes/VERSION index f2cbec8436..2f7ce82df6 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -8,6 +8,9 @@ Versions: 3.x (for earlier see VERSION-2.x) 3.1.0 (not yet released) +#221: Support alternate radixes when writing numeric values as strings + (requested by Christopher C) + (implementation by Davyd F) #650: `@JsonUnwrapped` prevents checks for `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES` (reported by Konstantin L) diff --git a/src/main/java/tools/jackson/databind/cfg/ConfigOverrides.java b/src/main/java/tools/jackson/databind/cfg/ConfigOverrides.java index 24b5ab0426..7bec4c9588 100644 --- a/src/main/java/tools/jackson/databind/cfg/ConfigOverrides.java +++ b/src/main/java/tools/jackson/databind/cfg/ConfigOverrides.java @@ -17,7 +17,7 @@ public class ConfigOverrides implements java.io.Serializable, Snapshottable { - private static final long serialVersionUID = 3L; + private static final long serialVersionUID = 4L; /** * Convenience value used as the default root setting. @@ -53,6 +53,11 @@ public class ConfigOverrides protected Boolean _defaultLeniency; + /** + * @since 3.1 + */ + protected JsonFormat.Value _defaultFormat; + /* /********************************************************************** /* Life cycle @@ -64,20 +69,33 @@ public ConfigOverrides() { INCLUDE_DEFAULT, JsonSetter.Value.empty(), DEFAULT_VISIBILITY_CHECKER, - null, null + null, null, JsonFormat.Value.empty() ); } protected ConfigOverrides(Map, MutableConfigOverride> overrides, JsonInclude.Value defIncl, JsonSetter.Value defSetter, VisibilityChecker defVisibility, - Boolean defMergeable, Boolean defLeniency) { + Boolean defMergeable, Boolean defLeniency, JsonFormat.Value defFormat) { _overrides = overrides; _defaultInclusion = defIncl; _defaultNullHandling = defSetter; _visibilityChecker = defVisibility; _defaultMergeable = defMergeable; _defaultLeniency = defLeniency; + _defaultFormat = defFormat; + } + + /** + * @deprecated since 3.1 + */ + @Deprecated + protected ConfigOverrides(Map, MutableConfigOverride> overrides, + JsonInclude.Value defIncl, JsonSetter.Value defSetter, + VisibilityChecker defVisibility, + Boolean defMergeable, Boolean defLeniency) { + this(overrides, defIncl, defSetter, defVisibility, defMergeable, defLeniency, + JsonFormat.Value.empty()); } @Override @@ -94,7 +112,7 @@ public ConfigOverrides snapshot() } return new ConfigOverrides(newOverrides, _defaultInclusion, _defaultNullHandling, _visibilityChecker, - _defaultMergeable, _defaultLeniency); + _defaultMergeable, _defaultLeniency, _defaultFormat); } /* @@ -128,26 +146,22 @@ public MutableConfigOverride findOrCreateOverride(Class type) { * override (if any). * * @return Default format settings for type; never null. - * - * @since 2.10 */ public JsonFormat.Value findFormatDefaults(Class type) { + JsonFormat.Value format = _defaultFormat; + if (_defaultLeniency != null) { + format = format.withLenient(_defaultLeniency); + } if (_overrides != null) { ConfigOverride override = _overrides.get(type); if (override != null) { - JsonFormat.Value format = override.getFormat(); - if (format != null) { - if (!format.hasLenient()) { - return format.withLenient(_defaultLeniency); - } - return format; + JsonFormat.Value formatOverride = override.getFormat(); + if (formatOverride != null) { + format = format.withOverrides(formatOverride); } } } - if (_defaultLeniency == null) { - return JsonFormat.Value.empty(); - } - return JsonFormat.Value.forLeniency(_defaultLeniency); + return format; } /* @@ -191,6 +205,17 @@ public VisibilityChecker getDefaultRecordVisibility() { : _visibilityChecker; } + /** + * Accessor for the global default {@code JsonFormat.Value} settings, + * not including possible per-type overrides (if you want to apply + * overrides, call {@link #findFormatDefaults} instead). + * + * @since 3.1 + */ + public JsonFormat.Value getDefaultFormat() { + return _defaultFormat; + } + /* /********************************************************************** /* Global defaults mutators @@ -227,6 +252,14 @@ public ConfigOverrides setDefaultVisibility(JsonAutoDetect.Value vis) { return this; } + /** + * @since 3.1 + */ + public ConfigOverrides setDefaultFormat(JsonFormat.Value format) { + _defaultFormat = format; + return this; + } + /* /********************************************************************** /* Standard methods (for diagnostics) @@ -241,6 +274,7 @@ public String toString() { .append(", merge=").append(_defaultMergeable) .append(", leniency=").append(_defaultLeniency) .append(", visibility=").append(_visibilityChecker) + .append(", format=").append(_defaultFormat) .append(", typed=") ; if (_overrides == null) { @@ -264,6 +298,6 @@ public String toString() { */ protected Map, MutableConfigOverride> _newMap() { - return new HashMap, MutableConfigOverride>(); + return new HashMap<>(); } } diff --git a/src/main/java/tools/jackson/databind/cfg/MapperBuilder.java b/src/main/java/tools/jackson/databind/cfg/MapperBuilder.java index 8ae92e8645..03c16f1dc9 100644 --- a/src/main/java/tools/jackson/databind/cfg/MapperBuilder.java +++ b/src/main/java/tools/jackson/databind/cfg/MapperBuilder.java @@ -7,6 +7,7 @@ import java.util.function.Consumer; import java.util.function.UnaryOperator; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -982,6 +983,16 @@ public B defaultLeniency(Boolean b) { return _this(); } + /** + * Method for configuring default format settings to use for serialization and deserialization. + * + * @since 3.1 + */ + public B defaultFormat(JsonFormat.Value format) { + _configOverrides.setDefaultFormat(format); + return _this(); + } + /* /********************************************************************** /* Changing settings, coercion config diff --git a/src/main/java/tools/jackson/databind/cfg/MapperConfig.java b/src/main/java/tools/jackson/databind/cfg/MapperConfig.java index 040227b2ab..9d5e2562fe 100644 --- a/src/main/java/tools/jackson/databind/cfg/MapperConfig.java +++ b/src/main/java/tools/jackson/databind/cfg/MapperConfig.java @@ -386,6 +386,17 @@ public JsonInclude.Value getDefaultInclusion(Class baseType, return result; } + + /** + * Accessor for global default format settings to apply for serialization and + * deserialization. + * The format obtained from this accessor should have the lowest precedence, + * overridable by per-type and/or per-property format overrides. + * + * @since 3.1 + */ + public abstract JsonFormat.Value getDefaultFormat(); + /** * Accessor for default format settings to use for serialization (and, to a degree * deserialization), considering baseline settings and per-type defaults diff --git a/src/main/java/tools/jackson/databind/cfg/MapperConfigBase.java b/src/main/java/tools/jackson/databind/cfg/MapperConfigBase.java index 0f71834ce4..8be445e31a 100644 --- a/src/main/java/tools/jackson/databind/cfg/MapperConfigBase.java +++ b/src/main/java/tools/jackson/databind/cfg/MapperConfigBase.java @@ -568,6 +568,11 @@ public final JsonInclude.Value getDefaultInclusion(Class baseType, return def.withOverrides(v); } + @Override // @since 3.1 + public JsonFormat.Value getDefaultFormat() { + return _configOverrides.getDefaultFormat(); + } + @Override public final JsonFormat.Value getDefaultPropertyFormat(Class type) { return _configOverrides.findFormatDefaults(type); diff --git a/src/main/java/tools/jackson/databind/deser/jdk/NumberDeserializers.java b/src/main/java/tools/jackson/databind/deser/jdk/NumberDeserializers.java index e0bc206d07..c55b9dc683 100644 --- a/src/main/java/tools/jackson/databind/deser/jdk/NumberDeserializers.java +++ b/src/main/java/tools/jackson/databind/deser/jdk/NumberDeserializers.java @@ -262,6 +262,17 @@ public Byte deserialize(JsonParser p, DeserializationContext ctxt) throws Jackso return _parseByte(p, ctxt); } + + /** + * @since 3.1 + */ + @Override + public ValueDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) + { + return RadixDeserializerFactory.createRadixStringDeserializer(this, + findFormatOverrides(ctxt, property, handledType())); + } + protected Byte _parseByte(JsonParser p, DeserializationContext ctxt) throws JacksonException { @@ -347,6 +358,17 @@ public ShortDeserializer(Class cls, Short nvl) super(cls, LogicalType.Integer, nvl, (short)0); } + /** + * @since 3.1 + */ + @Override + public ValueDeserializer createContextual(DeserializationContext ctxt, + BeanProperty property) + { + return RadixDeserializerFactory.createRadixStringDeserializer(this, + findFormatOverrides(ctxt, property, handledType())); + } + @Override public Short deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException @@ -528,6 +550,17 @@ public IntegerDeserializer(Class cls, Integer nvl) { @Override public boolean isCachable() { return true; } + /** + * @since 3.1 + */ + @Override + public ValueDeserializer createContextual(DeserializationContext ctxt, + BeanProperty property) + { + return RadixDeserializerFactory.createRadixStringDeserializer(this, + findFormatOverrides(ctxt, property, handledType())); + } + @Override public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { if (p.isExpectedNumberIntToken()) { @@ -569,8 +602,21 @@ public LongDeserializer(Class cls, Long nvl) { @Override public boolean isCachable() { return true; } + /** + * @since 3.1 + */ @Override - public Long deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + public ValueDeserializer createContextual(DeserializationContext ctxt, + BeanProperty property) + { + return RadixDeserializerFactory.createRadixStringDeserializer(this, + findFormatOverrides(ctxt, property, handledType())); + } + + @Override + public Long deserialize(JsonParser p, DeserializationContext ctxt) + throws JacksonException + { if (p.isExpectedNumberIntToken()) { return p.getLongValue(); } @@ -593,7 +639,8 @@ public FloatDeserializer(Class cls, Float nvl) { } @Override - public Float deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException + public Float deserialize(JsonParser p, DeserializationContext ctxt) + throws JacksonException { if (p.hasToken(JsonToken.VALUE_NUMBER_FLOAT)) { return p.getFloatValue(); @@ -939,6 +986,17 @@ public final LogicalType logicalType() { return LogicalType.Integer; } + /** + * @since 3.1 + */ + @Override + public ValueDeserializer createContextual(DeserializationContext ctxt, + BeanProperty property) + { + return RadixDeserializerFactory.createRadixStringDeserializer(this, + findFormatOverrides(ctxt, property, handledType())); + } + @Override public BigInteger deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { diff --git a/src/main/java/tools/jackson/databind/deser/jdk/RadixDeserializerFactory.java b/src/main/java/tools/jackson/databind/deser/jdk/RadixDeserializerFactory.java new file mode 100644 index 0000000000..95c4564e80 --- /dev/null +++ b/src/main/java/tools/jackson/databind/deser/jdk/RadixDeserializerFactory.java @@ -0,0 +1,36 @@ +package tools.jackson.databind.deser.jdk; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import tools.jackson.databind.deser.std.FromStringWithRadixToNumberDeserializer; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.deser.std.StdScalarDeserializer; + +/** + * Factory class for {@link FromStringWithRadixToNumberDeserializer} for deserializers in {@link tools.jackson.databind.deser.jdk.NumberDeserializers} + * + * @since 3.1 + */ +public class RadixDeserializerFactory +{ + public static StdDeserializer createRadixStringDeserializer( + StdScalarDeserializer initialDeser, + JsonFormat.Value formatOverrides) + { + if (formatOverrides != null && formatOverrides.getShape() == JsonFormat.Shape.STRING) { + if (isSerializeWithRadixOverride(formatOverrides)) { + int radix = formatOverrides.getRadix(); + return new FromStringWithRadixToNumberDeserializer(initialDeser, radix); + } + } + return initialDeser; + } + + /** + * Check if we have a proper {@link JsonFormat} annotation for serializing a number + * using an alternative radix specified in the annotation. + */ + private static boolean isSerializeWithRadixOverride(JsonFormat.Value format) { + return format.hasNonDefaultRadix(); + } +} diff --git a/src/main/java/tools/jackson/databind/deser/std/FromStringWithRadixToNumberDeserializer.java b/src/main/java/tools/jackson/databind/deser/std/FromStringWithRadixToNumberDeserializer.java new file mode 100644 index 0000000000..65cba92ce4 --- /dev/null +++ b/src/main/java/tools/jackson/databind/deser/std/FromStringWithRadixToNumberDeserializer.java @@ -0,0 +1,70 @@ +package tools.jackson.databind.deser.std; + +import java.math.BigInteger; + +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.util.ClassUtil; + +/** + * Deserializer used for a string that represents a number in specific radix (base). + * + * @since 3.1 + */ +public class FromStringWithRadixToNumberDeserializer + extends StdScalarDeserializer +{ + private final int radix; + + public FromStringWithRadixToNumberDeserializer(StdScalarDeserializer src, int radix) { + super(src); + this.radix = radix; + } + + @Override + public Number deserialize(JsonParser p, DeserializationContext ctxt) { + Class handledType = handledType(); + + // Should we allow (Integer) numbers? At least with "lenient" format settings? + if (!p.hasToken(JsonToken.VALUE_STRING)) { + ctxt.reportInputMismatch(handledType, + "Need String when deserializing a value using `FromStringWithRadixToNumberDeserializer` (radix: %d)", + radix); + } + + String text = p.getString(); + + // First, DoS check + p.streamReadConstraints().validateIntegerLength(text.length()); + + try { + if (handledType.equals(BigInteger.class)) { + return new BigInteger(text, radix); + } + // Map from wrappers to primitive + Class primitiveType = ClassUtil.primitiveType(handledType); + + // start with more likely types + if (primitiveType == long.class) { + return Long.parseLong(text, radix); + } + if (primitiveType == int.class) { + return Integer.parseInt(text, radix); + } + if (primitiveType == short.class) { + return Short.parseShort(text, radix); + } + if (primitiveType == byte.class) { + return Byte.parseByte(text, radix); + } + } catch (IllegalArgumentException iae) { + return (Number) ctxt.handleWeirdStringValue(handledType, text, + "not a valid representation of %s value with radix %d", + ClassUtil.nameOf(handledType), radix); + } + // Is this really true? + return ctxt.reportInputMismatch(handledType, + "Trying to deserialize a non-whole number with `NumberToStringWithRadixSerializer`"); + } +} diff --git a/src/main/java/tools/jackson/databind/deser/std/StdScalarDeserializer.java b/src/main/java/tools/jackson/databind/deser/std/StdScalarDeserializer.java index 1608b00fff..c230c47b72 100644 --- a/src/main/java/tools/jackson/databind/deser/std/StdScalarDeserializer.java +++ b/src/main/java/tools/jackson/databind/deser/std/StdScalarDeserializer.java @@ -15,6 +15,8 @@ public abstract class StdScalarDeserializer extends StdDeserializer protected StdScalarDeserializer(Class vc) { super(vc); } protected StdScalarDeserializer(JavaType valueType) { super(valueType); } + // @since 3.1 + protected StdScalarDeserializer(StdDeserializer src) { super(src); } protected StdScalarDeserializer(StdScalarDeserializer src) { super(src); } /* diff --git a/src/main/java/tools/jackson/databind/introspect/ConcreteBeanPropertyBase.java b/src/main/java/tools/jackson/databind/introspect/ConcreteBeanPropertyBase.java index 44abaf8970..913d916c4b 100644 --- a/src/main/java/tools/jackson/databind/introspect/ConcreteBeanPropertyBase.java +++ b/src/main/java/tools/jackson/databind/introspect/ConcreteBeanPropertyBase.java @@ -58,12 +58,10 @@ public JsonFormat.Value findFormatOverrides(MapperConfig config) { @Override public JsonFormat.Value findPropertyFormat(MapperConfig config, Class baseType) { - JsonFormat.Value v1 = config.getDefaultPropertyFormat(baseType); - JsonFormat.Value v2 = findFormatOverrides(config); - if (v1 == null) { - return (v2 == null) ? EMPTY_FORMAT : v2; - } - return (v2 == null) ? v1 : v1.withOverrides(v2); + JsonFormat.Value format = config.getDefaultPropertyFormat(baseType); + JsonFormat.Value overrides = findFormatOverrides(config); + + return (overrides == null) ? format : format.withOverrides(overrides); } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializer.java index b3b75a0ce5..f534391fd6 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializer.java @@ -47,18 +47,18 @@ public NumberSerializer(Class rawType) { } @Override - public ValueSerializer createContextual(SerializationContext prov, + public ValueSerializer createContextual(SerializationContext ctxt, BeanProperty property) { - JsonFormat.Value format = findFormatOverrides(prov, property, handledType()); + JsonFormat.Value format = findFormatOverrides(ctxt, property, handledType()); if (format != null) { switch (format.getShape()) { case STRING: // [databind#2264]: Need special handling for `BigDecimal` - if (((Class) handledType()) == BigDecimal.class) { + if (handledType() == BigDecimal.class) { return bigDecimalAsStringSerializer(); } - return ToStringSerializer.instance; + return NumberSerializer.createStringSerializer(ctxt, format, _isInt); default: } } @@ -66,7 +66,8 @@ public ValueSerializer createContextual(SerializationContext prov, } @Override - public void serialize(Number value, JsonGenerator g, SerializationContext provider) throws JacksonException + public void serialize(Number value, JsonGenerator g, SerializationContext ctxt) + throws JacksonException { // should mostly come in as one of these two: if (value instanceof BigDecimal bd) { @@ -106,8 +107,30 @@ public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType t } /** - * @since 2.10 + * Method used to create a string serializer for a number. If the number is integer, and configuration is set properly, + * we create an alternative radix serializer {@link NumberToStringWithRadixSerializer}. + * + * @since 3.1 */ + public static ToStringSerializerBase createStringSerializer(SerializationContext ctxt, + JsonFormat.Value format, boolean isInt) { + if (isInt && _hasRadixOverride(format)) { + int radix = format.getRadix(); + return new NumberToStringWithRadixSerializer(radix); + } + return ToStringSerializer.instance; + } + + /** + * Check if we have a proper {@link JsonFormat} annotation for serializing a number + * using an alternative radix specified in the annotation. + * + * @since 3.1 + */ + private static boolean _hasRadixOverride(JsonFormat.Value format) { + return format.hasNonDefaultRadix(); + } + public static ValueSerializer bigDecimalAsStringSerializer() { return BigDecimalAsStringSerializer.BD_INSTANCE; } @@ -129,7 +152,7 @@ public boolean isEmpty(SerializationContext prov, Object value) { } @Override - public void serialize(Object value, JsonGenerator gen, SerializationContext provider) + public void serialize(Object value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException { final String text; @@ -142,7 +165,7 @@ public void serialize(Object value, JsonGenerator gen, SerializationContext prov final String errorMsg = String.format( "Attempt to write plain `java.math.BigDecimal` (see JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN) with illegal scale (%d): needs to be between [-%d, %d]", bd.scale(), MAX_BIG_DECIMAL_SCALE, MAX_BIG_DECIMAL_SCALE); - provider.reportMappingProblem(errorMsg); + ctxt.reportMappingProblem(errorMsg); } text = bd.toPlainString(); } else { diff --git a/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializers.java b/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializers.java index 541d2e4269..b57cf966db 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializers.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/NumberSerializers.java @@ -13,7 +13,6 @@ import tools.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; import tools.jackson.databind.jsontype.TypeSerializer; import tools.jackson.databind.ser.std.StdScalarSerializer; -import tools.jackson.databind.ser.std.ToStringSerializer; /** * Container class for serializers used for handling standard JDK-provided @@ -94,7 +93,7 @@ public ValueSerializer createContextual(SerializationContext prov, if (((Class) handledType()) == BigDecimal.class) { return NumberSerializer.bigDecimalAsStringSerializer(); } - return ToStringSerializer.instance; + return NumberSerializer.createStringSerializer(prov, format, _isInt); default: } } diff --git a/src/main/java/tools/jackson/databind/ser/jdk/NumberToStringWithRadixSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/NumberToStringWithRadixSerializer.java new file mode 100644 index 0000000000..1d1f1ad797 --- /dev/null +++ b/src/main/java/tools/jackson/databind/ser/jdk/NumberToStringWithRadixSerializer.java @@ -0,0 +1,68 @@ +package tools.jackson.databind.ser.jdk; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.annotation.JacksonStdImpl; +import tools.jackson.databind.ser.std.ToStringSerializerBase; + +import java.math.BigInteger; + +/** + * Serializer used to convert numbers into a representation for a specified radix (base) and serialize + * the representation as string. + * + * @since 3.1 + */ +@JacksonStdImpl +public class NumberToStringWithRadixSerializer extends ToStringSerializerBase { + private final int radix; + + public NumberToStringWithRadixSerializer(int radix) { super(Object.class); + this.radix = radix; + } + + public NumberToStringWithRadixSerializer(Class handledType, int radix) { + super(handledType); + this.radix = radix; + } + + @Override + public boolean isEmpty(SerializationContext ctxt, Object value) { + return false; + } + + @Override + public void serialize(Object value, JsonGenerator gen, SerializationContext provider) + throws JacksonException + { + if (radix < Character.MIN_RADIX || radix > Character.MAX_RADIX) { + String errorMsg = String.format("To use a custom radix for string serialization, use radix within [%d, %d]", Character.MIN_RADIX, Character.MAX_RADIX); + provider.reportBadDefinition(handledType(), errorMsg); + } + + String text = ""; + if (value instanceof BigInteger) { + BigInteger bigIntegerValue = (BigInteger) value; + text = bigIntegerValue.toString(radix); + } else if (value instanceof Byte + || value instanceof Short + || value instanceof Integer + || value instanceof Long) { + long longValue = ((Number) value).longValue(); + text = Long.toString(longValue, radix); + } else { + provider.reportBadDefinition(handledType(), + "Trying to serialize a non-whole number with NumberToStringWithRadixSerializer"); + } + + gen.writeString(text); + + } + + @Override + public String valueToString(Object value) { + // should never be called + throw new IllegalStateException(); + } +} \ No newline at end of file diff --git a/src/test/java/tools/jackson/databind/format/DifferentRadixNumberFormatTest.java b/src/test/java/tools/jackson/databind/format/DifferentRadixNumberFormatTest.java new file mode 100644 index 0000000000..177f596a99 --- /dev/null +++ b/src/test/java/tools/jackson/databind/format/DifferentRadixNumberFormatTest.java @@ -0,0 +1,229 @@ +package tools.jackson.databind.format; + +import java.math.BigInteger; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +public class DifferentRadixNumberFormatTest extends DatabindTestUtil { + + private static final int HEX_RADIX = 16; + private static final int BINARY_RADIX = 2; + + static class IntegerWrapper { + public Integer value; + + public IntegerWrapper() {} + public IntegerWrapper(Integer v) { value = v; } + } + + static class IntWrapper { + public int value; + + public IntWrapper() {} + public IntWrapper(int v) { value = v; } + } + + static class AnnotatedMethodIntWrapper { + private int value; + + public AnnotatedMethodIntWrapper() { + } + public AnnotatedMethodIntWrapper(int v) { + value = v; + } + + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = HEX_RADIX) + public int getValue() { + return value; + } + } + + static class IncorrectlyAnnotatedMethodIntWrapper { + private int value; + + public IncorrectlyAnnotatedMethodIntWrapper() { + } + public IncorrectlyAnnotatedMethodIntWrapper(int v) { + value = v; + } + + @JsonFormat(shape = JsonFormat.Shape.STRING) + public int getValue() { + return value; + } + } + + static class AllIntegralTypeWrapper { + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public byte byteValue; + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public Byte ByteValue; + + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public short shortValue; + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public Short ShortValue; + + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public int intValue; + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public Integer IntegerValue; + + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public long longValue; + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public Long LongValue; + + @JsonFormat(shape = JsonFormat.Shape.STRING, radix = BINARY_RADIX) + public BigInteger bigInteger; + + public AllIntegralTypeWrapper() { + } + + public AllIntegralTypeWrapper(byte byteValue, Byte ByteValue, short shortValue, Short ShortValue, int intValue, + Integer IntegerValue, long longValue, Long LongValue, BigInteger bigInteger) { + this.byteValue = byteValue; + this.ByteValue = ByteValue; + this.shortValue = shortValue; + this.ShortValue = ShortValue; + this.intValue = intValue; + this.IntegerValue = IntegerValue; + this.longValue = longValue; + this.LongValue = LongValue; + this.bigInteger = bigInteger; + } + } + + private final ObjectMapper MAPPER = newJsonMapper(); + + @Test + void testIntSerializedAsHexString() + { + ObjectMapper mapper = jsonMapperBuilder() + .withConfigOverride(int.class, + o -> o.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING).withRadix(HEX_RADIX))) + .build(); + IntWrapper intialIntWrapper = new IntWrapper(10); + String expectedJson = a2q("{'value':'a'}"); + + String json = mapper.writeValueAsString(intialIntWrapper); + + assertEquals(expectedJson, json); + + IntWrapper readBackIntWrapper = mapper.readValue(expectedJson, IntWrapper.class); + assertEquals(intialIntWrapper.value, readBackIntWrapper.value); + + // And error case too: + try { + mapper.readValue(a2q("{'value':'XYZ'}"), IntWrapper.class); + fail("Should not pass"); + } catch (InvalidFormatException e) { + verifyException(e, "Cannot deserialize value of type `int` from String \"XYZ\""); + verifyException(e, "not a valid representation of `int` value with radix 16"); + } + } + + @Test + void testIntSerializedAsHexStringWithDefaultRadix() + { + ObjectMapper mapper = jsonMapperBuilder() + .defaultFormat(JsonFormat.Value.forRadix(HEX_RADIX).withShape(JsonFormat.Shape.STRING)) + .build(); + IntWrapper intialIntWrapper = new IntWrapper(10); + String expectedJson = a2q("{'value':'a'}"); + + String json = mapper.writeValueAsString(intialIntWrapper); + + assertEquals(expectedJson, json); + + IntWrapper readBackIntWrapper = mapper.readValue(expectedJson, IntWrapper.class); + + assertNotNull(readBackIntWrapper); + assertEquals(intialIntWrapper.value, readBackIntWrapper.value); + + // And error case too: + try { + mapper.readValue(a2q("{'value':'_x'}"), IntWrapper.class); + fail("Should not pass"); + } catch (InvalidFormatException e) { + verifyException(e, "Cannot deserialize value of type `int` from String \"_x\""); + verifyException(e, "not a valid representation of `int` value with radix 16"); + } + } + + @Test + void testAnnotatedAccessorSerializedAsHexString() + { + AnnotatedMethodIntWrapper initialIntWrapper = new AnnotatedMethodIntWrapper(10); + String expectedJson = a2q("{'value':'a'}"); + + String json = MAPPER.writeValueAsString(initialIntWrapper); + + assertEquals(expectedJson, json); + + AnnotatedMethodIntWrapper readBackIntWrapper = MAPPER.readValue(expectedJson, AnnotatedMethodIntWrapper.class); + + assertNotNull(readBackIntWrapper); + assertEquals(initialIntWrapper.value, readBackIntWrapper.value); + } + + @Test + void testAnnotatedAccessorWithoutRadixDoesNotThrow() + { + IncorrectlyAnnotatedMethodIntWrapper initialIntWrapper = new IncorrectlyAnnotatedMethodIntWrapper(10); + assertEquals(a2q("{'value':'10'}"), MAPPER.writeValueAsString(initialIntWrapper)); + } + + @Test + void testUsingDefaultConfigOverrideRadixToSerializeAsHexString() + { + ObjectMapper mapper = jsonMapperBuilder() + .withConfigOverride(Integer.class, + o -> o.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING).withRadix(HEX_RADIX))) + .build(); + IntegerWrapper intialIntegerWrapper = new IntegerWrapper(10); + String expectedJson = "{'value':'a'}"; + + String json = mapper.writeValueAsString(intialIntegerWrapper); + + assertEquals(a2q(expectedJson), json); + + IntegerWrapper readBackIntegerWrapper = mapper.readValue(a2q(expectedJson), IntegerWrapper.class); + + assertNotNull(readBackIntegerWrapper); + assertEquals(intialIntegerWrapper.value, readBackIntegerWrapper.value); + } + + @Test + void testAllIntegralTypesGetSerializedAsBinary() + { + AllIntegralTypeWrapper initialIntegralTypeWrapper = new AllIntegralTypeWrapper((byte) 1, + (byte) 2, (short) 3, (short) 4, 5, 6, 7L, 8L, new BigInteger("9")); + String expectedJson = a2q("{'byteValue':'1','ByteValue':'10','shortValue':'11','ShortValue':'100','intValue':'101','IntegerValue':'110','longValue':'111','LongValue':'1000','bigInteger':'1001'}"); + + assertEquals(expectedJson, MAPPER.writeValueAsString(initialIntegralTypeWrapper)); + + AllIntegralTypeWrapper readbackIntegralTypeWrapper = MAPPER.readValue(expectedJson, + AllIntegralTypeWrapper.class); + + assertNotNull(readbackIntegralTypeWrapper); + assertEquals(initialIntegralTypeWrapper.byteValue, readbackIntegralTypeWrapper.byteValue); + assertEquals(initialIntegralTypeWrapper.ByteValue, readbackIntegralTypeWrapper.ByteValue); + assertEquals(initialIntegralTypeWrapper.shortValue, readbackIntegralTypeWrapper.shortValue); + assertEquals(initialIntegralTypeWrapper.ShortValue, readbackIntegralTypeWrapper.ShortValue); + assertEquals(initialIntegralTypeWrapper.intValue, readbackIntegralTypeWrapper.intValue); + assertEquals(initialIntegralTypeWrapper.IntegerValue, readbackIntegralTypeWrapper.IntegerValue); + assertEquals(initialIntegralTypeWrapper.longValue, readbackIntegralTypeWrapper.longValue); + assertEquals(initialIntegralTypeWrapper.LongValue, readbackIntegralTypeWrapper.LongValue); + assertEquals(initialIntegralTypeWrapper.bigInteger, readbackIntegralTypeWrapper.bigInteger); + } +}