From 218abd2fc5cfccf920e514c59a41e26ffca639de Mon Sep 17 00:00:00 2001 From: Aaron Digulla Date: Sun, 4 Jun 2023 15:02:02 +0200 Subject: [PATCH 1/2] Support for canonical JSON output in Jackson. I kept all classes in the tests folder since there will be many more changes and it's easier when everything is in a single place. --- .../ser/CanonicalBigDecimalSerializer.java | 61 ++++ .../CanonicalBigDecimalSerializerTest.java | 52 ++++ .../databind/ser/CanonicalJsonFactory.java | 31 +++ .../databind/ser/CanonicalJsonTest.java | 260 ++++++++++++++++++ .../ser/CanonicalNumberGenerator.java | 65 +++++ .../databind/ser/CanonicalPrettyPrinter.java | 23 ++ .../jackson/databind/ser/ValueToString.java | 5 + src/test/resources/data/canonical-1.json | 1 + 8 files changed, 498 insertions(+) create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializerTest.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalJsonFactory.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalNumberGenerator.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalPrettyPrinter.java create mode 100644 src/test/java/tools/jackson/databind/ser/ValueToString.java create mode 100644 src/test/resources/data/canonical-1.json diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java b/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java new file mode 100644 index 0000000000..6bda346ed5 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java @@ -0,0 +1,61 @@ +package tools.jackson.databind.ser; + +import java.math.BigDecimal; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializerProvider; +import tools.jackson.databind.ser.std.StdSerializer; + +public class CanonicalBigDecimalSerializer extends StdSerializer + implements ValueToString { + + protected CanonicalBigDecimalSerializer() { + super(BigDecimal.class); + } + + @Override + public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider provider) + throws JacksonException { + CanonicalNumberGenerator.verifyBigDecimalRange(value, provider); + + String output = convert(value); + gen.writeNumber(output); + } + + @Override + public String convert(BigDecimal value) { + // TODO Convert to exponential form if necessary + BigDecimal stripped = value.stripTrailingZeros(); + int scale = stripped.scale(); + String text = stripped.toPlainString(); + if (scale == 0) { + return text; + } + + int pos = text.indexOf('.'); + int exp; + if (pos >= 0) { + exp = pos - 1; + + if (exp == 0) { + return text; + } + + text = text.substring(0, pos) + text.substring(pos + 1); + } else { + exp = -scale; + int end = text.length(); + while (end > 0 && text.charAt(end - 1) == '0') { + end --; + } + text = text.substring(0, end); + } + + if (text.length() == 1) { + return text + 'E' + exp; + } + + return text.substring(0, 1) + '.' + text.substring(1) + 'E' + exp; + } +} \ No newline at end of file diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializerTest.java b/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializerTest.java new file mode 100644 index 0000000000..380b31f453 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializerTest.java @@ -0,0 +1,52 @@ +package tools.jackson.databind.ser; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +public class CanonicalBigDecimalSerializerTest { + + @Test + void testCanonicalDecimalHandling_1() throws Exception { + assertSerialized("1", new BigDecimal("1")); + } + + @Test + void testCanonicalDecimalHandling_1_000() throws Exception { + assertSerialized("1", new BigDecimal("1.000")); + } + + @Test + void testCanonicalDecimalHandling_10_1000() throws Exception { + assertSerialized("1.01E1", new BigDecimal("10.1000")); + } + + @Test + void testCanonicalDecimalHandling_1000() throws Exception { + assertSerialized("1E3", new BigDecimal("1000")); + } + + @Test + void testCanonicalDecimalHandling_0_00000000010() throws Exception { + assertSerialized("0.0000000001", new BigDecimal("0.00000000010")); + } + + @Test + void testCanonicalDecimalHandling_1000_00010() throws Exception { + assertSerialized("1.0000001E3", new BigDecimal("1000.00010")); + } + + @Test + void testCanonicalHugeDecimalHandling() throws Exception { + BigDecimal actual = new BigDecimal("123456789123456789123456789123456789.123456789123456789123456789123456789123456789000"); + assertSerialized("1.23456789123456789123456789123456789123456789123456789123456789123456789123456789E35", actual); + } + + private void assertSerialized(String expected, BigDecimal actual) { + CanonicalBigDecimalSerializer serializer = new CanonicalBigDecimalSerializer(); + assertEquals(expected, serializer.convert(actual)); + } + +} diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalJsonFactory.java b/src/test/java/tools/jackson/databind/ser/CanonicalJsonFactory.java new file mode 100644 index 0000000000..250117bcb5 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalJsonFactory.java @@ -0,0 +1,31 @@ +package tools.jackson.databind.ser; + +import java.io.Writer; +import java.math.BigDecimal; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.ObjectWriteContext; +import tools.jackson.core.io.IOContext; +import tools.jackson.core.json.JsonFactory; + +/** + * TODO Fix double numbers. This feels like a very heavy solution plus I can't + * use the JsonFactory.builder(). + */ +public class CanonicalJsonFactory extends JsonFactory { + private static final long serialVersionUID = 1L; + + private ValueToString _serializer; + + public CanonicalJsonFactory(ValueToString serializer) { + this._serializer = serializer; + } + + @Override + protected JsonGenerator _createGenerator(ObjectWriteContext writeCtxt, IOContext ioCtxt, Writer out) + throws JacksonException { + JsonGenerator delegate = super._createGenerator(writeCtxt, ioCtxt, out); + return new CanonicalNumberGenerator(delegate, _serializer); + } +} diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java b/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java new file mode 100644 index 0000000000..315962e6be --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java @@ -0,0 +1,260 @@ +package tools.jackson.databind.ser; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; // TODO JUnit 4 or 5 for tests? + +import com.google.common.collect.Lists; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.StreamWriteFeature; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.databind.BaseTest; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.SerializerProvider; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.json.JsonMapper.Builder; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.node.JsonNodeFactory; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.ser.std.StdSerializer; + +class CanonicalJsonTest extends BaseTest { + + private static final ObjectMapper MAPPER = newJsonMapper(); + private static final double NEGATIVE_ZERO = -0.; + + // TODO There are several ways to make sure we really have a negative sign. + // Double.toString(NEGATIVE_ZERO) seems to be the most simple. + @Test + void testSignOfNegativeZero() { + assertEquals("-0.0", Double.toString(Math.signum(NEGATIVE_ZERO))); + } + + @Test + void testSignOfNegativeZero2() { + long bits = Double.doubleToRawLongBits(NEGATIVE_ZERO); + assertTrue(bits < 0); + } + + @Test + void testSignOfNegativeZero3() { + long sign = 1L << (Double.SIZE - 1); // Highest bit represents the sign + long bits = Double.doubleToRawLongBits(NEGATIVE_ZERO); + assertEquals(sign, bits & sign); + } + + @Test + void testSignOfNegativeZero4() { + assertEquals("-0.0", Double.toString(NEGATIVE_ZERO)); + } + + @Test + void testNegativeZeroIsEqualToZero() { + assertEquals(0.0, NEGATIVE_ZERO, 1e-9); + } + + @Test + void testCanonicalBigDecimalSerializationTrailingZeros() throws Exception { + assertSerialized("1", new BigDecimal("1.0000"), newCanonicalMapperBuilder()); + } + + @Test + void testCanonicalNegativeZeroBigDecimal() throws Exception { + assertSerialized("0", new BigDecimal("-0"), newCanonicalMapperBuilder()); + } + + @Test + void testCanonicalNegativeZeroBigDecimal2() throws Exception { + assertSerialized("0", new BigDecimal(NEGATIVE_ZERO), newCanonicalMapperBuilder()); + } + + @Test + void testCanonicalNegativeZeroDouble() throws Exception { + assertSerialized("0", NEGATIVE_ZERO, newCanonicalMapperBuilder()); + } + + @Test + void testCanonicalDecimalHandling() throws Exception { + assertSerialized("1.01E1", new BigDecimal("10.1000"), newCanonicalMapperBuilder()); + } + + @Test + void testCanonicalHugeDecimalHandling() throws Exception { + BigDecimal actual = new BigDecimal("123456789123456789123456789123456789.123456789123456789123456789123456789123456789000"); + assertSerialized("1.23456789123456789123456789123456789123456789123456789123456789123456789123456789E35", actual, newCanonicalMapperBuilder()); + } + + @Test + void testPrettyDecimalHandling() throws Exception { + assertSerialized("10.1", new BigDecimal("10.1000"), newPrettyCanonicalMapperBuilder()); + } + + @Test + void testPrettyHugeDecimalHandling() throws Exception { + BigDecimal actual = new BigDecimal("123456789123456789123456789123456789.123456789123456789123456789123456789123456789000"); + assertSerialized("123456789123456789123456789123456789.123456789123456789123456789123456789123456789", actual, newPrettyCanonicalMapperBuilder()); + } + + @Test + void testCanonicalJsonSerialization() throws Exception { + JsonNode expected = loadData("canonical-1.json"); + JsonNode actual = buildTestData(); + + assertCanonicalJson(expected, actual); + } + + @Test + void testCanonicalJsonSerializationRandomizedChildren() throws Exception { + JsonNode expected = loadData("canonical-1.json"); + JsonNode actual = randomize(buildTestData()); + + assertCanonicalJson(expected, actual); + } + + @Test + void testPrettyJsonSerialization() throws Exception { + JsonNode expected = loadData("canonical-1.json"); + JsonNode actual = buildTestData(); + + assertPrettyJson(expected, actual); + } + + @Test + void testPrettyJsonSerializationRandomizedChildren() throws Exception { + JsonNode expected = loadData("canonical-1.json"); + JsonNode actual = randomize(buildTestData()); + + assertPrettyJson(expected, actual); + } + + private void assertSerialized(String expected, Object input, JsonMapper.Builder builder) { + ObjectMapper mapper = builder.build(); + + String actual = mapper.writeValueAsString(input); + assertEquals(expected, actual); + } + + private Builder newCanonicalMapperBuilder() { + CanonicalBigDecimalSerializer serializer = new CanonicalBigDecimalSerializer(); + JacksonModule bigDecimalModule = new BigDecimalModule(serializer); + + JsonFactory factory = new CanonicalJsonFactory(serializer); + return sharedConfig(jsonMapperBuilder(factory)) + .addModules(bigDecimalModule); + } + + private Builder newPrettyCanonicalMapperBuilder() { + PrettyBigDecimalSerializer serializer = new PrettyBigDecimalSerializer(); + JacksonModule bigDecimalModule = new BigDecimalModule(serializer); + + JsonFactory factory = new CanonicalJsonFactory(serializer); + return sharedConfig(jsonMapperBuilder(factory)) // + .enable(SerializationFeature.INDENT_OUTPUT) // + .enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN) // + .defaultPrettyPrinter(CanonicalPrettyPrinter.INSTANCE) // + .addModules(bigDecimalModule); + } + + private JsonMapper.Builder sharedConfig(JsonMapper.Builder builder) { + return builder.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY); + } + + private JsonNode randomize(JsonNode input) { + if (input instanceof ObjectNode) { + List> copy = Lists.newArrayList(input.fields()); + Collections.shuffle(copy); + + Map randomized = new LinkedHashMap<>(); + copy.forEach(entry -> { + randomized.put(entry.getKey(), randomize(entry.getValue())); + }); + + return new ObjectNode(JsonNodeFactory.instance, randomized); + } else { + return input; + } + } + + private void assertCanonicalJson(JsonNode expected, JsonNode actual) { + ObjectMapper mapper = newCanonicalMapperBuilder().build(); + assertEquals(serialize(expected, mapper), serialize(actual, mapper)); + } + + private void assertPrettyJson(JsonNode expected, JsonNode actual) { + ObjectMapper mapper = newPrettyCanonicalMapperBuilder().build(); + assertEquals(serialize(expected, mapper), serialize(actual, mapper)); + } + + private String serialize(JsonNode input, ObjectMapper mapper) { + // TODO Is there a better way to sort the keys than deserializing the whole tree? + Object obj = mapper.treeToValue(input, Object.class); + return mapper.writeValueAsString(obj); + } + + private JsonNode loadData(String fileName) throws IOException { + String resource = "/data/" + fileName; + try (InputStream stream = getClass().getResourceAsStream(resource)) { + // TODO Formatting ok? JUnit 4 or 5 here? + assertNotNull("Missing resource " + resource, stream); + + return MAPPER.readTree(stream); + } + } + + private JsonNode buildTestData() { + return new ObjectNode(JsonNodeFactory.instance) // + .put("-0", NEGATIVE_ZERO) // + .put("-1", -1) // + .put("0.1", new BigDecimal("0.100")) // + .put("1", new BigDecimal("1")) // + .put("10.1", new BigDecimal("10.100")) // + .put("emoji", "\uD83D\uDE03") // + .put("escape", "\u001B") // + .put("lone surrogate", "\uDEAD") // + .put("whitespace", " \t\n\r") // + ; + } + + public static class PrettyBigDecimalSerializer extends StdSerializer + implements ValueToString { + + protected PrettyBigDecimalSerializer() { + super(BigDecimal.class); + } + + @Override + public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider provider) + throws JacksonException { + CanonicalNumberGenerator.verifyBigDecimalRange(value, provider); + + String output = convert(value); + gen.writeNumber(output); + } + + @Override + public String convert(BigDecimal value) { + return value.stripTrailingZeros().toPlainString(); + } + } + + public static class BigDecimalModule extends SimpleModule { + private static final long serialVersionUID = 1L; + + public BigDecimalModule(StdSerializer serializer) { + addSerializer(BigDecimal.class, serializer); + } + } +} diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalNumberGenerator.java b/src/test/java/tools/jackson/databind/ser/CanonicalNumberGenerator.java new file mode 100644 index 0000000000..d4fb74fea3 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalNumberGenerator.java @@ -0,0 +1,65 @@ +package tools.jackson.databind.ser; + +import java.math.BigDecimal; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.exc.StreamWriteException; +import tools.jackson.core.util.JsonGeneratorDelegate; +import tools.jackson.databind.SerializerProvider; + +public class CanonicalNumberGenerator extends JsonGeneratorDelegate { + + /** + * TODO The constant should be public or the verify method. Copied from + * `jackson-databing` class `NumberSerializer` + */ + protected final static int MAX_BIG_DECIMAL_SCALE = 9999; + + private final ValueToString _bigDecimalToString; + + public CanonicalNumberGenerator(JsonGenerator gen, ValueToString bigDecimalToString) { + super(gen); + this._bigDecimalToString = bigDecimalToString; + } + + @Override + public JsonGenerator writeNumber(double v) throws JacksonException { + BigDecimal wrapper = BigDecimal.valueOf(v); + return writeNumber(wrapper); + } + + @Override + public JsonGenerator writeNumber(BigDecimal v) throws JacksonException { + if (!verifyBigDecimalRange(v)) { + // TODO Is there a better way? I can't call delegate._reportError(). + String msg = bigDecimalOutOfRangeError(v); + throw new StreamWriteException(this, msg); + } + + String converted = _bigDecimalToString.convert(v); + return delegate.writeNumber(converted); + } + + public static boolean verifyBigDecimalRange(BigDecimal value, SerializerProvider provider) { + boolean result = verifyBigDecimalRange(value); + + if (!result) { + provider.reportMappingProblem(bigDecimalOutOfRangeError(value)); + } + + return result; + } + + public static boolean verifyBigDecimalRange(BigDecimal value) { + int scale = value.scale(); + return ((scale >= -MAX_BIG_DECIMAL_SCALE) && (scale <= MAX_BIG_DECIMAL_SCALE)); + } + + // TODO Everyone should use the same method + public static String bigDecimalOutOfRangeError(BigDecimal value) { + return String.format( + "Attempt to write plain `java.math.BigDecimal` (see StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN) with illegal scale (%d): needs to be between [-%d, %d]", + value.scale(), MAX_BIG_DECIMAL_SCALE, MAX_BIG_DECIMAL_SCALE); + } +} diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalPrettyPrinter.java b/src/test/java/tools/jackson/databind/ser/CanonicalPrettyPrinter.java new file mode 100644 index 0000000000..d54cc4caca --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalPrettyPrinter.java @@ -0,0 +1,23 @@ +package tools.jackson.databind.ser; + +import tools.jackson.core.PrettyPrinter; +import tools.jackson.core.util.DefaultIndenter; +import tools.jackson.core.util.DefaultPrettyPrinter; +import tools.jackson.core.util.Separators; + +public class CanonicalPrettyPrinter extends DefaultPrettyPrinter { + private static final long serialVersionUID = 1L; + private static final DefaultIndenter STABLE_INDENTEER = new DefaultIndenter(" ", "\n"); + + public static final PrettyPrinter INSTANCE = new CanonicalPrettyPrinter() + .withObjectIndenter(STABLE_INDENTEER); + + @Override + public DefaultPrettyPrinter withSeparators(Separators separators) { + _separators = separators; + // TODO it would be great if it was possible to configure this without + // overriding + _nameValueSeparatorWithSpaces = separators.getObjectNameValueSeparator() + " "; + return this; + } +} diff --git a/src/test/java/tools/jackson/databind/ser/ValueToString.java b/src/test/java/tools/jackson/databind/ser/ValueToString.java new file mode 100644 index 0000000000..939ab409da --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/ValueToString.java @@ -0,0 +1,5 @@ +package tools.jackson.databind.ser; + +public interface ValueToString { + String convert(T value); +} diff --git a/src/test/resources/data/canonical-1.json b/src/test/resources/data/canonical-1.json new file mode 100644 index 0000000000..a9b62c644a --- /dev/null +++ b/src/test/resources/data/canonical-1.json @@ -0,0 +1 @@ +{"-0":0,"-1":-1,"0.1":1.0E-1,"1":1,"10.1":1.01E1,"emoji":"😃","escape":"\u001B","lone surrogate":"\uDEAD","whitespace":" \t\n\r"} \ No newline at end of file From 588fe01498b13f7732212f5acacf6ceca1deaba0 Mon Sep 17 00:00:00 2001 From: Aaron Digulla Date: Tue, 6 Jun 2023 21:44:31 +0200 Subject: [PATCH 2/2] Extracted setup code into CanonicalJsonMapper plus a few helper classes. Added JsonAssert with several assertion methods that can be used similar to assertEquals(). --- .../ser/CanonicalBigDecimalSerializer.java | 14 +++ .../databind/ser/CanonicalJsonMapper.java | 48 ++++++++++ .../databind/ser/CanonicalJsonModule.java | 18 ++++ .../databind/ser/CanonicalJsonTest.java | 93 +++---------------- .../CanonicalNumberSerializerProvider.java | 11 +++ .../jackson/databind/ser/JsonAssert.java | 54 +++++++++++ .../databind/ser/JsonTestResource.java | 47 ++++++++++ .../ser/PrettyBigDecimalSerializer.java | 44 +++++++++ 8 files changed, 248 insertions(+), 81 deletions(-) create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalJsonMapper.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalJsonModule.java create mode 100644 src/test/java/tools/jackson/databind/ser/CanonicalNumberSerializerProvider.java create mode 100644 src/test/java/tools/jackson/databind/ser/JsonAssert.java create mode 100644 src/test/java/tools/jackson/databind/ser/JsonTestResource.java create mode 100644 src/test/java/tools/jackson/databind/ser/PrettyBigDecimalSerializer.java diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java b/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java index 6bda346ed5..e28b8b8a42 100644 --- a/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java +++ b/src/test/java/tools/jackson/databind/ser/CanonicalBigDecimalSerializer.java @@ -10,6 +10,20 @@ public class CanonicalBigDecimalSerializer extends StdSerializer implements ValueToString { + public static final CanonicalBigDecimalSerializer INSTANCE = new CanonicalBigDecimalSerializer(); + + public static final CanonicalNumberSerializerProvider PROVIDER = new CanonicalNumberSerializerProvider() { + @Override + public StdSerializer getNumberSerializer() { + return INSTANCE; + } + + @Override + public ValueToString getValueToString() { + return INSTANCE; + } + }; + protected CanonicalBigDecimalSerializer() { super(BigDecimal.class); } diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalJsonMapper.java b/src/test/java/tools/jackson/databind/ser/CanonicalJsonMapper.java new file mode 100644 index 0000000000..1851fde2ef --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalJsonMapper.java @@ -0,0 +1,48 @@ +package tools.jackson.databind.ser; + +import tools.jackson.core.StreamWriteFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +public class CanonicalJsonMapper { // TODO It would be great if we could extend JsonMapper but the return type of builder() is incompatible + + public static class Builder { // TODO Can't extend MapperBuilder because that needs JsonFactory as ctor arg and we only have this later + private CanonicalNumberSerializerProvider _numberSerializerProvider = CanonicalBigDecimalSerializer.PROVIDER; + private boolean _enablePrettyPrinting = false; + + private Builder() { + // Don't allow to create except via builder method + } + + public Builder prettyPrint() { + _enablePrettyPrinting = true; + _numberSerializerProvider = PrettyBigDecimalSerializer.PROVIDER; + return this; + } + + public JsonMapper build() { + CanonicalJsonFactory jsonFactory = new CanonicalJsonFactory(_numberSerializerProvider.getValueToString()); + CanonicalJsonModule module = new CanonicalJsonModule(_numberSerializerProvider.getNumberSerializer()); + + JsonMapper.Builder builder = JsonMapper.builder(jsonFactory) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) // + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) // + .addModule(module); + + if (_enablePrettyPrinting) { + builder = builder // + .enable(SerializationFeature.INDENT_OUTPUT) // + .enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN) // + .defaultPrettyPrinter(CanonicalPrettyPrinter.INSTANCE) // + ; + } + + return builder.build(); + } + } + + public static CanonicalJsonMapper.Builder builder() { + return new Builder(); + } +} diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalJsonModule.java b/src/test/java/tools/jackson/databind/ser/CanonicalJsonModule.java new file mode 100644 index 0000000000..d193e7d5c1 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalJsonModule.java @@ -0,0 +1,18 @@ +package tools.jackson.databind.ser; + +import java.math.BigDecimal; + +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.StdSerializer; + +public class CanonicalJsonModule extends SimpleModule { + private static final long serialVersionUID = 1L; + + public CanonicalJsonModule() { + this(CanonicalBigDecimalSerializer.INSTANCE); + } + + public CanonicalJsonModule(StdSerializer numberSerializer) { + addSerializer(BigDecimal.class, numberSerializer); + } +} diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java b/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java index 315962e6be..49d73a6fb5 100644 --- a/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java +++ b/src/test/java/tools/jackson/databind/ser/CanonicalJsonTest.java @@ -1,7 +1,5 @@ package tools.jackson.databind.ser; -import java.io.IOException; -import java.io.InputStream; import java.math.BigDecimal; import java.util.Collections; import java.util.LinkedHashMap; @@ -12,19 +10,10 @@ import com.google.common.collect.Lists; -import tools.jackson.core.JacksonException; -import tools.jackson.core.JsonGenerator; -import tools.jackson.core.StreamWriteFeature; -import tools.jackson.core.json.JsonFactory; import tools.jackson.databind.BaseTest; -import tools.jackson.databind.JacksonModule; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.SerializationFeature; -import tools.jackson.databind.SerializerProvider; import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.json.JsonMapper.Builder; import tools.jackson.databind.module.SimpleModule; import tools.jackson.databind.node.JsonNodeFactory; import tools.jackson.databind.node.ObjectNode; @@ -32,8 +21,9 @@ class CanonicalJsonTest extends BaseTest { - private static final ObjectMapper MAPPER = newJsonMapper(); private static final double NEGATIVE_ZERO = -0.; + private static final JsonAssert JSON_ASSERT = new JsonAssert(); + private static final JsonTestResource CANONICAL_1 = new JsonTestResource("/data/canonical-1.json"); // TODO There are several ways to make sure we really have a negative sign. // Double.toString(NEGATIVE_ZERO) seems to be the most simple. @@ -109,7 +99,7 @@ void testPrettyHugeDecimalHandling() throws Exception { @Test void testCanonicalJsonSerialization() throws Exception { - JsonNode expected = loadData("canonical-1.json"); + JsonNode expected = JSON_ASSERT.loadResource(CANONICAL_1); JsonNode actual = buildTestData(); assertCanonicalJson(expected, actual); @@ -117,7 +107,7 @@ void testCanonicalJsonSerialization() throws Exception { @Test void testCanonicalJsonSerializationRandomizedChildren() throws Exception { - JsonNode expected = loadData("canonical-1.json"); + JsonNode expected = JSON_ASSERT.loadResource(CANONICAL_1); JsonNode actual = randomize(buildTestData()); assertCanonicalJson(expected, actual); @@ -125,51 +115,29 @@ void testCanonicalJsonSerializationRandomizedChildren() throws Exception { @Test void testPrettyJsonSerialization() throws Exception { - JsonNode expected = loadData("canonical-1.json"); JsonNode actual = buildTestData(); - assertPrettyJson(expected, actual); + JSON_ASSERT.assertJson(CANONICAL_1, actual); } @Test void testPrettyJsonSerializationRandomizedChildren() throws Exception { - JsonNode expected = loadData("canonical-1.json"); JsonNode actual = randomize(buildTestData()); - assertPrettyJson(expected, actual); + JSON_ASSERT.assertJson(CANONICAL_1, actual); } - private void assertSerialized(String expected, Object input, JsonMapper.Builder builder) { - ObjectMapper mapper = builder.build(); - + private void assertSerialized(String expected, Object input, JsonMapper mapper) { String actual = mapper.writeValueAsString(input); assertEquals(expected, actual); } - private Builder newCanonicalMapperBuilder() { - CanonicalBigDecimalSerializer serializer = new CanonicalBigDecimalSerializer(); - JacksonModule bigDecimalModule = new BigDecimalModule(serializer); - - JsonFactory factory = new CanonicalJsonFactory(serializer); - return sharedConfig(jsonMapperBuilder(factory)) - .addModules(bigDecimalModule); - } - - private Builder newPrettyCanonicalMapperBuilder() { - PrettyBigDecimalSerializer serializer = new PrettyBigDecimalSerializer(); - JacksonModule bigDecimalModule = new BigDecimalModule(serializer); - - JsonFactory factory = new CanonicalJsonFactory(serializer); - return sharedConfig(jsonMapperBuilder(factory)) // - .enable(SerializationFeature.INDENT_OUTPUT) // - .enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN) // - .defaultPrettyPrinter(CanonicalPrettyPrinter.INSTANCE) // - .addModules(bigDecimalModule); + private JsonMapper newCanonicalMapperBuilder() { + return CanonicalJsonMapper.builder().build(); } - private JsonMapper.Builder sharedConfig(JsonMapper.Builder builder) { - return builder.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) - .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY); + private JsonMapper newPrettyCanonicalMapperBuilder() { + return CanonicalJsonMapper.builder().prettyPrint().build(); } private JsonNode randomize(JsonNode input) { @@ -189,12 +157,7 @@ private JsonNode randomize(JsonNode input) { } private void assertCanonicalJson(JsonNode expected, JsonNode actual) { - ObjectMapper mapper = newCanonicalMapperBuilder().build(); - assertEquals(serialize(expected, mapper), serialize(actual, mapper)); - } - - private void assertPrettyJson(JsonNode expected, JsonNode actual) { - ObjectMapper mapper = newPrettyCanonicalMapperBuilder().build(); + ObjectMapper mapper = newCanonicalMapperBuilder(); assertEquals(serialize(expected, mapper), serialize(actual, mapper)); } @@ -204,16 +167,6 @@ private String serialize(JsonNode input, ObjectMapper mapper) { return mapper.writeValueAsString(obj); } - private JsonNode loadData(String fileName) throws IOException { - String resource = "/data/" + fileName; - try (InputStream stream = getClass().getResourceAsStream(resource)) { - // TODO Formatting ok? JUnit 4 or 5 here? - assertNotNull("Missing resource " + resource, stream); - - return MAPPER.readTree(stream); - } - } - private JsonNode buildTestData() { return new ObjectNode(JsonNodeFactory.instance) // .put("-0", NEGATIVE_ZERO) // @@ -228,28 +181,6 @@ private JsonNode buildTestData() { ; } - public static class PrettyBigDecimalSerializer extends StdSerializer - implements ValueToString { - - protected PrettyBigDecimalSerializer() { - super(BigDecimal.class); - } - - @Override - public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider provider) - throws JacksonException { - CanonicalNumberGenerator.verifyBigDecimalRange(value, provider); - - String output = convert(value); - gen.writeNumber(output); - } - - @Override - public String convert(BigDecimal value) { - return value.stripTrailingZeros().toPlainString(); - } - } - public static class BigDecimalModule extends SimpleModule { private static final long serialVersionUID = 1L; diff --git a/src/test/java/tools/jackson/databind/ser/CanonicalNumberSerializerProvider.java b/src/test/java/tools/jackson/databind/ser/CanonicalNumberSerializerProvider.java new file mode 100644 index 0000000000..f4cd16b8fa --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/CanonicalNumberSerializerProvider.java @@ -0,0 +1,11 @@ +package tools.jackson.databind.ser; + +import java.math.BigDecimal; + +import tools.jackson.databind.ser.std.StdSerializer; + +// TODO I need to implement an interface and ValueSerializer which is an abstract class. This seems to be the only solution. +public interface CanonicalNumberSerializerProvider { + StdSerializer getNumberSerializer(); + ValueToString getValueToString(); +} diff --git a/src/test/java/tools/jackson/databind/ser/JsonAssert.java b/src/test/java/tools/jackson/databind/ser/JsonAssert.java new file mode 100644 index 0000000000..3d34e588ca --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/JsonAssert.java @@ -0,0 +1,54 @@ +package tools.jackson.databind.ser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.io.IOException; +import java.io.InputStream; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +public class JsonAssert { + + private JsonMapper _mapper; // TODO If we don't want to allow people to configure this, then everything in here can be static which would make it look a lot like JUnit Assertions. + + public JsonAssert() { + // TODO Does it make sense to use compact canonical JSON in unit tests? The diff of the comparison will be hard to use. + _mapper = CanonicalJsonMapper.builder().prettyPrint().build(); + } + + public void assertJson(JsonTestResource expected, Object actual) { + assertJson(loadResource(expected), actual); + } + + public void assertJson(JsonNode expected, Object actual) { + assertEquals(serialize(expected), serialize(actual)); + } + + public String serialize(JsonNode input) { + // TODO Is there a better way to sort the keys than deserializing the whole tree? + Object obj = _mapper.treeToValue(input, Object.class); + return _mapper.writeValueAsString(obj); + } + + public String serialize(Object data) { + if (data instanceof JsonNode) { + return serialize((JsonNode)data); + } + + // TODO Sorting mostly works except for properties defined by @JsonTypeInfo + return _mapper.writeValueAsString(data); + } + + public JsonNode loadResource(JsonTestResource resource) { + try (InputStream stream = resource.getInputStream()) { + // TODO Formatting ok? JUnit 4 or 5 here? + assertNotNull("Missing resource " + resource, stream); + + return _mapper.readTree(stream); + } catch (IOException e) { + throw new AssertionError("Error loading " + resource, e); + } + } +} diff --git a/src/test/java/tools/jackson/databind/ser/JsonTestResource.java b/src/test/java/tools/jackson/databind/ser/JsonTestResource.java new file mode 100644 index 0000000000..25c8afcfe7 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/JsonTestResource.java @@ -0,0 +1,47 @@ +package tools.jackson.databind.ser; + +import java.io.InputStream; +import java.net.URL; + +public class JsonTestResource { + + private String resourceName; + private ClassLoader classLoader; + + public JsonTestResource(String resourceName) { + this(resourceName, JsonTestResource.class.getClassLoader()); + } + + public JsonTestResource(String resourceName, ClassLoader classLoader) { + this.resourceName = fix(resourceName); + this.classLoader = classLoader; + } + + private static String fix(String resourceName) { + if (resourceName.startsWith("/")) { + return resourceName.substring(1); + } + return resourceName; + } + + public URL getURL() { + return classLoader.getResource(resourceName); + } + + public InputStream getInputStream() { + return classLoader.getResourceAsStream(resourceName); + } + + @Override + public String toString() { + URL url = getURL(); + String content; + if (url == null) { + content = "resourceName=" + resourceName + " (not on classpath)"; + } else { + content = "url=" + url; + } + + return getClass().getSimpleName() + "(" + content + ")"; + } +} diff --git a/src/test/java/tools/jackson/databind/ser/PrettyBigDecimalSerializer.java b/src/test/java/tools/jackson/databind/ser/PrettyBigDecimalSerializer.java new file mode 100644 index 0000000000..14cf318a53 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/PrettyBigDecimalSerializer.java @@ -0,0 +1,44 @@ +package tools.jackson.databind.ser; + +import java.math.BigDecimal; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializerProvider; +import tools.jackson.databind.ser.std.StdSerializer; + +public class PrettyBigDecimalSerializer extends StdSerializer + implements ValueToString { + + public static final PrettyBigDecimalSerializer INSTANCE = new PrettyBigDecimalSerializer(); + + public static final CanonicalNumberSerializerProvider PROVIDER = new CanonicalNumberSerializerProvider() { + @Override + public StdSerializer getNumberSerializer() { + return INSTANCE; + } + + @Override + public ValueToString getValueToString() { + return INSTANCE; + } + }; + + protected PrettyBigDecimalSerializer() { + super(BigDecimal.class); + } + + @Override + public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider provider) + throws JacksonException { + CanonicalNumberGenerator.verifyBigDecimalRange(value, provider); + + String output = convert(value); + gen.writeNumber(output); + } + + @Override + public String convert(BigDecimal value) { + return value.stripTrailingZeros().toPlainString(); + } +} \ No newline at end of file