diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java index 3ac3993..7662637 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/AmountRepresentation.java @@ -6,6 +6,16 @@ */ public enum AmountRepresentation { + /** + * Default representation (inherit module-level configuration). + * When used in field-level annotation, indicates that the field should use + * the module's default representation. + * + * + * @since 3.1 + */ + DEFAULT, + /** * Decimal number representation, where amount is (de)serialized as decimal number equal * to {@link org.joda.money.Money Money}'s amount, e.g. {@code 12.34} for diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java new file mode 100644 index 0000000..9c36122 --- /dev/null +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java @@ -0,0 +1,49 @@ +package tools.jackson.datatype.jodamoney; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.fasterxml.jackson.annotation.JacksonAnnotation; + +/** + * Annotation for configuring serialization and deserialization of {@link org.joda.money.Money Money} + * at the field level. This annotation allows per-property override of the amount representation + * used when converting Money to/from JSON. + *

+ * When applied to a field, getter, or constructor parameter, this annotation takes precedence over + * the module-level configuration set via {@link JodaMoneyModule#withAmountRepresentation(AmountRepresentation)}. + *

+ * Example usage: + *

+ * public class Payment {
+ *     @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ *     private Money amount;
+ *
+ *     @JodaMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT)
+ *     private Money fee;
+ * }
+ * 
+ * + * @see AmountRepresentation + * @see JodaMoneyModule#withAmountRepresentation(AmountRepresentation) + * + * @since 3.1 + */ +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotation +public @interface JodaMoney { + + /** + * Specifies the amount representation to use for this property. + *

+ * Defaults to {@link AmountRepresentation#DEFAULT}, which means the property + * will use the module-level configuration or the built-in default + * ({@link AmountRepresentation#DECIMAL_NUMBER}). + * + * @return the amount representation to use + */ + AmountRepresentation amountRepresentation() default AmountRepresentation.DEFAULT; +} diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java index a713743..10f0b4f 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoneyModule.java @@ -53,6 +53,7 @@ public void setupModule(SetupContext context) public JodaMoneyModule withAmountRepresentation(final AmountRepresentation representation) { switch (representation) { + case DEFAULT: case DECIMAL_NUMBER: return new JodaMoneyModule(DecimalNumberAmountConverter.getInstance()); case DECIMAL_STRING: diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java index a887572..b3f115f 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneyDeserializer.java @@ -4,11 +4,14 @@ import java.util.Arrays; import java.util.Collection; +import com.fasterxml.jackson.annotation.JsonFormat; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; import tools.jackson.core.JsonToken; +import tools.jackson.databind.BeanProperty; import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import tools.jackson.databind.deser.std.StdDeserializer; import tools.jackson.databind.jsontype.TypeDeserializer; import tools.jackson.databind.type.LogicalType; @@ -20,8 +23,8 @@ public class MoneyDeserializer extends StdDeserializer { - private final String F_AMOUNT = "amount"; - private final String F_CURRENCY = "currency"; + private static final String F_AMOUNT = "amount"; + private static final String F_CURRENCY = "currency"; private final AmountConverter amountConverter; // Kept to maintain backward compatibility with 2.x @@ -35,6 +38,74 @@ public MoneyDeserializer() { this.amountConverter = requireNonNull(amountConverter, "amount converter cannot be null"); } + @Override + public ValueDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + if (property == null) { + return this; + } + + AmountRepresentation effectiveRepresentation = _resolveRepresentation(property, ctxt); + + if (effectiveRepresentation == null || effectiveRepresentation == AmountRepresentation.DEFAULT) { + // Keep current converter (module-level default) + return this; + } + + AmountConverter newConverter = _getConverterForRepresentation(effectiveRepresentation); + if (newConverter == this.amountConverter) { + return this; + } + + return new MoneyDeserializer(newConverter); + } + + private AmountRepresentation _resolveRepresentation(BeanProperty property, DeserializationContext ctxt) { + // Priority 1: @JodaMoney annotation + JodaMoney jodaMoney = property.getAnnotation(JodaMoney.class); + if (jodaMoney != null && jodaMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jodaMoney.amountRepresentation(); + } + + // Priority 2: @JsonFormat mapping + JsonFormat.Value format = property.findPropertyFormat(ctxt.getConfig(), Money.class); + if (format != null && format.getShape() != JsonFormat.Shape.ANY) { + AmountRepresentation mapped = _mapShapeToRepresentation(format.getShape()); + if (mapped != null) { + return mapped; + } + } + + // Priority 3 & 4: Module default or built-in default (already in amountConverter) + return null; + } + + private AmountRepresentation _mapShapeToRepresentation(JsonFormat.Shape shape) { + switch (shape) { + case STRING: + return AmountRepresentation.DECIMAL_STRING; + case NUMBER: + case NUMBER_FLOAT: + return AmountRepresentation.DECIMAL_NUMBER; + case NUMBER_INT: + return AmountRepresentation.MINOR_CURRENCY_UNIT; + default: + return null; // Ignore other shapes + } + } + + private AmountConverter _getConverterForRepresentation(AmountRepresentation representation) { + switch (representation) { + case DECIMAL_NUMBER: + return DecimalNumberAmountConverter.getInstance(); + case DECIMAL_STRING: + return DecimalStringAmountConverter.getInstance(); + case MINOR_CURRENCY_UNIT: + return MinorCurrencyUnitAmountConverter.getInstance(); + default: + return this.amountConverter; + } + } + @Override public LogicalType logicalType() { // structured, hence POJO diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java index b2d989e..dd7d471 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/MoneySerializer.java @@ -2,10 +2,13 @@ import org.joda.money.Money; +import com.fasterxml.jackson.annotation.JsonFormat; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonGenerator; import tools.jackson.core.JsonToken; import tools.jackson.core.type.WritableTypeId; +import tools.jackson.databind.BeanProperty; +import tools.jackson.databind.ValueSerializer; import tools.jackson.databind.SerializationContext; import tools.jackson.databind.jsontype.TypeSerializer; @@ -25,6 +28,74 @@ public MoneySerializer() { this.amountConverter = requireNonNull(amountConverter, "amount converter cannot be null"); } + @Override + public ValueSerializer createContextual(SerializationContext ctxt, BeanProperty property) { + if (property == null) { + return this; + } + + AmountRepresentation effectiveRepresentation = _resolveRepresentation(property, ctxt); + + if (effectiveRepresentation == null || effectiveRepresentation == AmountRepresentation.DEFAULT) { + // Keep current converter (module-level default) + return this; + } + + AmountConverter newConverter = _getConverterForRepresentation(effectiveRepresentation); + if (newConverter == this.amountConverter) { + return this; + } + + return new MoneySerializer(newConverter); + } + + private AmountRepresentation _resolveRepresentation(BeanProperty property, SerializationContext ctxt) { + // Priority 1: @JodaMoney annotation + JodaMoney jodaMoney = property.getAnnotation(JodaMoney.class); + if (jodaMoney != null && jodaMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jodaMoney.amountRepresentation(); + } + + // Priority 2: @JsonFormat mapping + JsonFormat.Value format = property.findPropertyFormat(ctxt.getConfig(), Money.class); + if (format != null && format.getShape() != JsonFormat.Shape.ANY) { + AmountRepresentation mapped = _mapShapeToRepresentation(format.getShape()); + if (mapped != null) { + return mapped; + } + } + + // Priority 3 & 4: Module default or built-in default (already in amountConverter) + return null; + } + + private AmountRepresentation _mapShapeToRepresentation(JsonFormat.Shape shape) { + switch (shape) { + case STRING: + return AmountRepresentation.DECIMAL_STRING; + case NUMBER: + case NUMBER_FLOAT: + return AmountRepresentation.DECIMAL_NUMBER; + case NUMBER_INT: + return AmountRepresentation.MINOR_CURRENCY_UNIT; + default: + return null; // Ignore other shapes + } + } + + private AmountConverter _getConverterForRepresentation(AmountRepresentation representation) { + switch (representation) { + case DECIMAL_NUMBER: + return DecimalNumberAmountConverter.getInstance(); + case DECIMAL_STRING: + return DecimalStringAmountConverter.getInstance(); + case MINOR_CURRENCY_UNIT: + return MinorCurrencyUnitAmountConverter.getInstance(); + default: + return this.amountConverter; + } + } + @Override public void serialize(final Money value, final JsonGenerator g, final SerializationContext ctxt) diff --git a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java new file mode 100644 index 0000000..2d86fdb --- /dev/null +++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java @@ -0,0 +1,337 @@ +package tools.jackson.datatype.jodamoney; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.databind.ObjectMapper; +import org.joda.money.CurrencyUnit; +import org.joda.money.Money; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("MoneyFieldLevelRepresentation tests") +public class MoneyFieldLevelRepresentationTest extends ModuleTestBase { + + @Nested + @DisplayName("@JodaMoney annotation tests") + class JodaMoneyAnnotationTests { + + @Test + @DisplayName("should serialize field with DECIMAL_STRING when @JodaMoney specified") + void fieldWithDecimalString() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithFieldAnnotations payment = new PaymentWithFieldAnnotations( + Money.parse("EUR 12.34"), + Money.parse("EUR 5.67"), + Money.parse("EUR 100.00") + ); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); + assertTrue(json.contains("\"fee\":{\"amount\":567")); + assertTrue(json.contains("\"total\":{\"amount\":100.00")); + } + + @Test + @DisplayName("should deserialize field with DECIMAL_STRING when @JodaMoney specified") + void deserializeFieldWithDecimalString() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + String json = "{\"amount\":{\"amount\":\"12.34\",\"currency\":\"EUR\"}," + + "\"fee\":{\"amount\":567,\"currency\":\"EUR\"}," + + "\"total\":{\"amount\":100.00,\"currency\":\"EUR\"}}"; + + // when + PaymentWithFieldAnnotations payment = mapper.readValue(json, PaymentWithFieldAnnotations.class); + + // then + assertEquals(Money.parse("EUR 12.34"), payment.amount); + assertEquals(Money.parse("EUR 5.67"), payment.fee); + assertEquals(Money.parse("EUR 100.00"), payment.total); + } + + @Test + @DisplayName("should serialize getter with @JodaMoney annotation") + void getterWithAnnotation() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithGetterAnnotations payment = new PaymentWithGetterAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); + } + + @Test + @DisplayName("should deserialize constructor parameter with @JodaMoney annotation") + void constructorParameterWithAnnotation() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + String json = "{\"amount\":{\"amount\":\"12.34\",\"currency\":\"EUR\"}}"; + + // when + PaymentWithConstructorAnnotations payment = mapper.readValue(json, PaymentWithConstructorAnnotations.class); + + // then + assertEquals(Money.parse("EUR 12.34"), payment.getAmount()); + } + } + + @Nested + @DisplayName("Mixed configuration tests") + class MixedConfigurationTests { + + @Test + @DisplayName("should use field annotation over module default") + void fieldOverridesModuleDefault() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_NUMBER)); + PaymentWithFieldAnnotations payment = new PaymentWithFieldAnnotations( + Money.parse("EUR 12.34"), + Money.parse("EUR 5.67"), + Money.parse("EUR 100.00") + ); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // override to string + assertTrue(json.contains("\"fee\":{\"amount\":567")); // override to int + assertTrue(json.contains("\"total\":{\"amount\":100.00")); // uses module default (number) + } + + @Test + @DisplayName("should use @JodaMoney over @JsonFormat when both present") + void jodaMoneyWinsOverJsonFormat() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithBothAnnotations payment = new PaymentWithBothAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JodaMoney wins (STRING) + } + + @Test + @DisplayName("should round-trip with mixed representations") + void roundTripWithMixedRepresentations() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithFieldAnnotations original = new PaymentWithFieldAnnotations( + Money.parse("EUR 12.34"), + Money.parse("EUR 5.67"), + Money.parse("EUR 100.00") + ); + + // when + String json = mapper.writeValueAsString(original); + PaymentWithFieldAnnotations deserialized = mapper.readValue(json, PaymentWithFieldAnnotations.class); + + // then + assertEquals(original.amount, deserialized.amount); + assertEquals(original.fee, deserialized.fee); + assertEquals(original.total, deserialized.total); + } + } + + @Nested + @DisplayName("DEFAULT representation tests") + class DefaultRepresentationTests { + + @Test + @DisplayName("should inherit module default when @JodaMoney(DEFAULT) specified") + void defaultInheritsModuleConfig() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_STRING)); + PaymentWithDefaultAnnotation payment = new PaymentWithDefaultAnnotation(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // inherits DECIMAL_STRING from module + } + } + + @Nested + @DisplayName("Mix-in annotation tests") + class MixinAnnotationTests { + + @Test + @DisplayName("should apply @JodaMoney from mix-in to override representation") + void jodaMoneyMixinOverridesDefault() throws Exception { + // setup + ObjectMapper mapper = mapperWithModuleBuilder() + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJodaMoney.class) + .build(); + PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // mix-in applies DECIMAL_STRING + } + + @Test + @DisplayName("should apply @JsonFormat from mix-in to override representation") + void jsonFormatMixinOverridesDefault() throws Exception { + // setup + ObjectMapper mapper = mapperWithModuleBuilder() + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonFormat.class) + .build(); + PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":1234")); // mix-in applies NUMBER_INT -> MINOR_CURRENCY_UNIT + } + + @Test + @DisplayName("should round-trip with mix-in annotations") + void roundTripWithMixin() throws Exception { + // setup + ObjectMapper mapper = mapperWithModuleBuilder() + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJodaMoney.class) + .build(); + PaymentWithoutAnnotations original = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(original); + PaymentWithoutAnnotations deserialized = mapper.readValue(json, PaymentWithoutAnnotations.class); + + // then + assertEquals(original.amount, deserialized.amount); + } + + @Test + @DisplayName("should apply @JodaMoney mix-in over @JsonFormat mix-in when both present") + void jodaMoneyMixinWinsOverJsonFormatMixin() throws Exception { + // setup + ObjectMapper mapper = mapperWithModuleBuilder() + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithBothAnnotations.class) + .build(); + PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JodaMoney (STRING) wins over @JsonFormat (NUMBER_INT) + } + } + + // Test POJOs + + static class PaymentWithFieldAnnotations { + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + public Money amount; + + @JodaMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT) + public Money fee; + + public Money total; // No annotation - uses module default + + @JsonCreator + public PaymentWithFieldAnnotations( + @JsonProperty("amount") Money amount, + @JsonProperty("fee") Money fee, + @JsonProperty("total") Money total + ) { + this.amount = amount; + this.fee = fee; + this.total = total; + } + } + + static class PaymentWithGetterAnnotations { + private Money amount; + + public PaymentWithGetterAnnotations(Money amount) { + this.amount = amount; + } + + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + public Money getAmount() { + return amount; + } + } + + static class PaymentWithConstructorAnnotations { + private final Money amount; + + @JsonCreator + public PaymentWithConstructorAnnotations( + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JsonProperty("amount") Money amount + ) { + this.amount = amount; + } + + public Money getAmount() { + return amount; + } + } + + static class PaymentWithBothAnnotations { + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + + public PaymentWithBothAnnotations(Money amount) { + this.amount = amount; + } + } + + static class PaymentWithDefaultAnnotation { + @JodaMoney(amountRepresentation = AmountRepresentation.DEFAULT) + public Money amount; + + public PaymentWithDefaultAnnotation(Money amount) { + this.amount = amount; + } + } + + // POJOs without annotations for mix-in tests + static class PaymentWithoutAnnotations { + public Money amount; + + public PaymentWithoutAnnotations(Money amount) { + this.amount = amount; + } + + public PaymentWithoutAnnotations() { + } + } + + // Mix-in classes + abstract static class PaymentMixinWithJodaMoney { + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + public Money amount; + } + + abstract static class PaymentMixinWithJsonFormat { + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + } + + abstract static class PaymentMixinWithBothAnnotations { + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + } +} diff --git a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java new file mode 100644 index 0000000..04cea61 --- /dev/null +++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java @@ -0,0 +1,243 @@ +package tools.jackson.datatype.jodamoney; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.databind.ObjectMapper; +import org.joda.money.Money; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("MoneyFormatShapeMapping tests") +public class MoneyFormatShapeMappingTest extends ModuleTestBase { + + @Nested + @DisplayName("@JsonFormat shape mapping tests") + class ShapeMappingTests { + + @Test + @DisplayName("should map STRING to DECIMAL_STRING representation") + void stringShapeToDecimalString() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithStringShape payment = new PaymentWithStringShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); + } + + @Test + @DisplayName("should map NUMBER to DECIMAL_NUMBER representation") + void numberShapeToDecimalNumber() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithNumberShape payment = new PaymentWithNumberShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":12.34")); + } + + @Test + @DisplayName("should map NUMBER_FLOAT to DECIMAL_NUMBER representation") + void numberFloatShapeToDecimalNumber() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithNumberFloatShape payment = new PaymentWithNumberFloatShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":12.34")); + } + + @Test + @DisplayName("should map NUMBER_INT to MINOR_CURRENCY_UNIT representation") + void numberIntShapeToMinorCurrencyUnit() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithNumberIntShape payment = new PaymentWithNumberIntShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":1234")); + } + + @Test + @DisplayName("should ignore unsupported shape and use module default") + void unsupportedShapeUsesDefault() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_STRING)); + PaymentWithObjectShape payment = new PaymentWithObjectShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // uses module default + } + } + + @Nested + @DisplayName("Round-trip tests") + class RoundTripTests { + + @Test + @DisplayName("should round-trip with STRING shape") + void roundTripStringShape() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithStringShape original = new PaymentWithStringShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(original); + PaymentWithStringShape deserialized = mapper.readValue(json, PaymentWithStringShape.class); + + // then + assertEquals(original.amount, deserialized.amount); + } + + @Test + @DisplayName("should round-trip with NUMBER_INT shape") + void roundTripNumberIntShape() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithNumberIntShape original = new PaymentWithNumberIntShape(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(original); + PaymentWithNumberIntShape deserialized = mapper.readValue(json, PaymentWithNumberIntShape.class); + + // then + assertEquals(original.amount, deserialized.amount); + } + } + + @Nested + @DisplayName("Multiple shapes tests") + class MultipleShapesTests { + + @Test + @DisplayName("should serialize multiple fields with different shapes") + void multipleFieldsWithDifferentShapes() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithMultipleShapes payment = new PaymentWithMultipleShapes( + Money.parse("EUR 12.34"), + Money.parse("EUR 5.67"), + Money.parse("EUR 100.00") + ); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertTrue(json.contains("\"stringAmount\":{\"amount\":\"12.34\"")); + assertTrue(json.contains("\"numberAmount\":{\"amount\":5.67")); + assertTrue(json.contains("\"intAmount\":{\"amount\":10000")); + } + + @Test + @DisplayName("should deserialize multiple fields with different shapes") + void deserializeMultipleFieldsWithDifferentShapes() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + String json = "{\"stringAmount\":{\"amount\":\"12.34\",\"currency\":\"EUR\"}," + + "\"numberAmount\":{\"amount\":5.67,\"currency\":\"EUR\"}," + + "\"intAmount\":{\"amount\":10000,\"currency\":\"EUR\"}}"; + + // when + PaymentWithMultipleShapes payment = mapper.readValue(json, PaymentWithMultipleShapes.class); + + // then + assertEquals(Money.parse("EUR 12.34"), payment.stringAmount); + assertEquals(Money.parse("EUR 5.67"), payment.numberAmount); + assertEquals(Money.parse("EUR 100.00"), payment.intAmount); + } + } + + // Test POJOs + + static class PaymentWithStringShape { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Money amount; + + @JsonCreator + public PaymentWithStringShape(@JsonProperty("amount") Money amount) { + this.amount = amount; + } + } + + static class PaymentWithNumberShape { + @JsonFormat(shape = JsonFormat.Shape.NUMBER) + public Money amount; + + @JsonCreator + public PaymentWithNumberShape(@JsonProperty("amount") Money amount) { + this.amount = amount; + } + } + + static class PaymentWithNumberFloatShape { + @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT) + public Money amount; + + @JsonCreator + public PaymentWithNumberFloatShape(@JsonProperty("amount") Money amount) { + this.amount = amount; + } + } + + static class PaymentWithNumberIntShape { + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + + @JsonCreator + public PaymentWithNumberIntShape(@JsonProperty("amount") Money amount) { + this.amount = amount; + } + } + + static class PaymentWithObjectShape { + @JsonFormat(shape = JsonFormat.Shape.OBJECT) + public Money amount; + + @JsonCreator + public PaymentWithObjectShape(@JsonProperty("amount") Money amount) { + this.amount = amount; + } + } + + static class PaymentWithMultipleShapes { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Money stringAmount; + + @JsonFormat(shape = JsonFormat.Shape.NUMBER) + public Money numberAmount; + + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money intAmount; + + @JsonCreator + public PaymentWithMultipleShapes( + @JsonProperty("stringAmount") Money stringAmount, + @JsonProperty("numberAmount") Money numberAmount, + @JsonProperty("intAmount") Money intAmount + ) { + this.stringAmount = stringAmount; + this.numberAmount = numberAmount; + this.intAmount = intAmount; + } + } +} diff --git a/release-notes/CREDITS b/release-notes/CREDITS new file mode 100644 index 0000000..c159870 --- /dev/null +++ b/release-notes/CREDITS @@ -0,0 +1,18 @@ +Here are people who have contributed to the development of Jackson JSON processor +Miscellaneous datatypes +(version numbers in brackets indicate release in which the problem was fixed) + +Tatu Saloranta (tatu.saloranta@iki.fi): author of `org.json` (aka `json-org`), + `jsr-353`, `[jakarta-]jsonp` modules, co-author of other modules + +Iurii Ignatko (welandaz@github): author of `joda-money` module (added in 2.11) + +Christopher Smith (chrylis@github): author of `jakarta-mail` module (added in 2.13) + +---------------------------------------------------------------------------- + +@sri-adarsh-kumar + +* Contributed #76: (joda-money) Add field-level amount representation for Joda-Money + (`@JodaMoney` annotation) + (3.1.0) diff --git a/release-notes/VERSION b/release-notes/VERSION index 7d2f1cb..fc8897c 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -13,6 +13,12 @@ Modules: === Releases === ------------------------------------------------------------------------ +3.1.0 (not yet released) + +#76: (joda-money) Add field-level amount representation for Joda-Money (`@JodaMoney` + annotation) + (contributed by @sri-adarsh-kumar) + 3.0.1 (21-Oct-2025) No changes since 3.0.0