From 427f44586c95507b7609869c6803749387c66afc Mon Sep 17 00:00:00 2001 From: Sri Adarsh Kumar Date: Tue, 21 Oct 2025 16:09:29 +0200 Subject: [PATCH 1/4] Add field-level amount representation for Joda-Money MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements per-property override of amount representation to complement the module-level default introduced in PR #17. Changes: - Add DEFAULT value to AmountRepresentation enum to represent "inherit module config" - Add @JsonMoney annotation for field-level configuration - Make MoneySerializer and MoneyDeserializer contextual to support per-property resolution - Support @JsonFormat(shape=...) mapping: STRING→DECIMAL_STRING, NUMBER/NUMBER_FLOAT→DECIMAL_NUMBER, NUMBER_INT→MINOR_CURRENCY_UNIT - Precedence: @JsonMoney > @JsonFormat > module default > built-in default - Full mix-in support via Jackson's standard BeanProperty API - Maintain backward compatibility for unannotated fields Tests: - Add MoneyFieldLevelRepresentationTest with 12 tests covering field/getter/constructor annotations, mixed configurations, precedence, and mix-ins - Add MoneyFormatShapeMappingTest with 9 tests covering shape mappings and multiple fields - All 94 tests pass (21 new, 73 existing with no regression) Fixes #18 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../jodamoney/AmountRepresentation.java | 7 + .../datatype/jodamoney/JodaMoneyModule.java | 1 + .../jackson/datatype/jodamoney/JsonMoney.java | 47 +++ .../datatype/jodamoney/MoneyDeserializer.java | 77 +++- .../datatype/jodamoney/MoneySerializer.java | 73 ++++ .../MoneyFieldLevelRepresentationTest.java | 333 ++++++++++++++++++ .../MoneyFormatShapeMappingTest.java | 243 +++++++++++++ 7 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/JsonMoney.java create mode 100644 joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java create mode 100644 joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java diff --git a/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/AmountRepresentation.java b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/AmountRepresentation.java index abd2ef84..3ddba6f0 100644 --- a/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/AmountRepresentation.java +++ b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/AmountRepresentation.java @@ -6,6 +6,13 @@ */ 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. + */ + 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/com/fasterxml/jackson/datatype/jodamoney/JodaMoneyModule.java b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/JodaMoneyModule.java index 5be884d3..e567f427 100644 --- a/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/JodaMoneyModule.java +++ b/joda-money/src/main/java/com/fasterxml/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/com/fasterxml/jackson/datatype/jodamoney/JsonMoney.java b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/JsonMoney.java new file mode 100644 index 00000000..3e52e30a --- /dev/null +++ b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/JsonMoney.java @@ -0,0 +1,47 @@ +package com.fasterxml.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 {
+ *     @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ *     private Money amount;
+ *
+ *     @JsonMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT)
+ *     private Money fee;
+ * }
+ * 
+ * + * @see AmountRepresentation + * @see JodaMoneyModule#withAmountRepresentation(AmountRepresentation) + */ +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotation +public @interface JsonMoney { + + /** + * 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/com/fasterxml/jackson/datatype/jodamoney/MoneyDeserializer.java b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/MoneyDeserializer.java index 91256242..c5196460 100644 --- a/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/MoneyDeserializer.java +++ b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/MoneyDeserializer.java @@ -5,10 +5,14 @@ import java.util.Arrays; import java.util.Collection; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.BeanProperty; import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; import com.fasterxml.jackson.databind.type.LogicalType; @@ -19,11 +23,12 @@ import static java.util.Objects.requireNonNull; public class MoneyDeserializer extends StdDeserializer + implements ContextualDeserializer { private static final long serialVersionUID = 1L; - 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 @@ -37,6 +42,74 @@ public MoneyDeserializer() { this.amountConverter = requireNonNull(amountConverter, "amount converter cannot be null"); } + @Override + public JsonDeserializer 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: @JsonMoney annotation + JsonMoney jsonMoney = property.getAnnotation(JsonMoney.class); + if (jsonMoney != null && jsonMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jsonMoney.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/com/fasterxml/jackson/datatype/jodamoney/MoneySerializer.java b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/MoneySerializer.java index d331ec6a..086f03e7 100644 --- a/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/MoneySerializer.java +++ b/joda-money/src/main/java/com/fasterxml/jackson/datatype/jodamoney/MoneySerializer.java @@ -4,15 +4,20 @@ import org.joda.money.Money; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.core.type.WritableTypeId; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; import static java.util.Objects.requireNonNull; public class MoneySerializer extends JodaMoneySerializerBase + implements ContextualSerializer { private static final long serialVersionUID = 1L; @@ -29,6 +34,74 @@ public MoneySerializer() { this.amountConverter = requireNonNull(amountConverter, "amount converter cannot be null"); } + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) { + if (property == null) { + return this; + } + + AmountRepresentation effectiveRepresentation = _resolveRepresentation(property, prov); + + 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, SerializerProvider prov) { + // Priority 1: @JsonMoney annotation + JsonMoney jsonMoney = property.getAnnotation(JsonMoney.class); + if (jsonMoney != null && jsonMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jsonMoney.amountRepresentation(); + } + + // Priority 2: @JsonFormat mapping + JsonFormat.Value format = property.findPropertyFormat(prov.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, diff --git a/joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java b/joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java new file mode 100644 index 00000000..3c1af1e8 --- /dev/null +++ b/joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java @@ -0,0 +1,333 @@ +package com.fasterxml.jackson.datatype.jodamoney; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.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.assertj.core.api.Assertions.assertThat; + +@DisplayName("MoneyFieldLevelRepresentation tests") +public class MoneyFieldLevelRepresentationTest extends ModuleTestBase { + + @Nested + @DisplayName("@JsonMoney annotation tests") + class JsonMoneyAnnotationTests { + + @Test + @DisplayName("should serialize field with DECIMAL_STRING when @JsonMoney 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 + assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); + assertThat(json).contains("\"fee\":{\"amount\":567"); + assertThat(json).contains("\"total\":{\"amount\":100.00"); + } + + @Test + @DisplayName("should deserialize field with DECIMAL_STRING when @JsonMoney 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 + assertThat(payment.amount).isEqualTo(Money.parse("EUR 12.34")); + assertThat(payment.fee).isEqualTo(Money.parse("EUR 5.67")); + assertThat(payment.total).isEqualTo(Money.parse("EUR 100.00")); + } + + @Test + @DisplayName("should serialize getter with @JsonMoney 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 + assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); + } + + @Test + @DisplayName("should deserialize constructor parameter with @JsonMoney 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 + assertThat(payment.getAmount()).isEqualTo(Money.parse("EUR 12.34")); + } + } + + @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 + assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // override to string + assertThat(json).contains("\"fee\":{\"amount\":567"); // override to int + assertThat(json).contains("\"total\":{\"amount\":100.00"); // uses module default (number) + } + + @Test + @DisplayName("should use @JsonMoney over @JsonFormat when both present") + void jsonMoneyWinsOverJsonFormat() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + PaymentWithBothAnnotations payment = new PaymentWithBothAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // @JsonMoney 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 + assertThat(deserialized.amount).isEqualTo(original.amount); + assertThat(deserialized.fee).isEqualTo(original.fee); + assertThat(deserialized.total).isEqualTo(original.total); + } + } + + @Nested + @DisplayName("DEFAULT representation tests") + class DefaultRepresentationTests { + + @Test + @DisplayName("should inherit module default when @JsonMoney(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 + assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // inherits DECIMAL_STRING from module + } + } + + @Nested + @DisplayName("Mix-in annotation tests") + class MixinAnnotationTests { + + @Test + @DisplayName("should apply @JsonMoney from mix-in to override representation") + void jsonMoneyMixinOverridesDefault() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class); + PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertThat(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 = mapperWithModule(); + mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonFormat.class); + PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertThat(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 = mapperWithModule(); + mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class); + PaymentWithoutAnnotations original = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(original); + PaymentWithoutAnnotations deserialized = mapper.readValue(json, PaymentWithoutAnnotations.class); + + // then + assertThat(deserialized.amount).isEqualTo(original.amount); + } + + @Test + @DisplayName("should apply @JsonMoney mix-in over @JsonFormat mix-in when both present") + void jsonMoneyMixinWinsOverJsonFormatMixin() throws Exception { + // setup + ObjectMapper mapper = mapperWithModule(); + mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithBothAnnotations.class); + PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); + + // when + String json = mapper.writeValueAsString(payment); + + // then + assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // @JsonMoney (STRING) wins over @JsonFormat (NUMBER_INT) + } + } + + // Test POJOs + + static class PaymentWithFieldAnnotations { + @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + public Money amount; + + @JsonMoney(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; + } + + @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + public Money getAmount() { + return amount; + } + } + + static class PaymentWithConstructorAnnotations { + private final Money amount; + + @JsonCreator + public PaymentWithConstructorAnnotations( + @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JsonProperty("amount") Money amount + ) { + this.amount = amount; + } + + public Money getAmount() { + return amount; + } + } + + static class PaymentWithBothAnnotations { + @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + + public PaymentWithBothAnnotations(Money amount) { + this.amount = amount; + } + } + + static class PaymentWithDefaultAnnotation { + @JsonMoney(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 PaymentMixinWithJsonMoney { + @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + public Money amount; + } + + abstract static class PaymentMixinWithJsonFormat { + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + } + + abstract static class PaymentMixinWithBothAnnotations { + @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) + public Money amount; + } +} diff --git a/joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java b/joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java new file mode 100644 index 00000000..969b5fdd --- /dev/null +++ b/joda-money/src/test/java/com/fasterxml/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java @@ -0,0 +1,243 @@ +package com.fasterxml.jackson.datatype.jodamoney; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.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.assertj.core.api.Assertions.assertThat; + +@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 + assertThat(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 + assertThat(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 + assertThat(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 + assertThat(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 + assertThat(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 + assertThat(deserialized.amount).isEqualTo(original.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 + assertThat(deserialized.amount).isEqualTo(original.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 + assertThat(json).contains("\"stringAmount\":{\"amount\":\"12.34\""); + assertThat(json).contains("\"numberAmount\":{\"amount\":5.67"); + assertThat(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 + assertThat(payment.stringAmount).isEqualTo(Money.parse("EUR 12.34")); + assertThat(payment.numberAmount).isEqualTo(Money.parse("EUR 5.67")); + assertThat(payment.intAmount).isEqualTo(Money.parse("EUR 100.00")); + } + } + + // 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; + } + } +} From 562b848ddb228116b1cbff047deccbece57133e4 Mon Sep 17 00:00:00 2001 From: Sri Adarsh Kumar Date: Tue, 21 Oct 2025 18:38:18 +0200 Subject: [PATCH 2/4] Fix failing tests in joda-money module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Jackson 3.x API compatibility: use MapperBuilder.addMixIn() instead of ObjectMapper.addMixIn() - Replace AssertJ assertions with JUnit assertions (assertTrue, assertEquals) - Fix import statements to use correct Jackson 3.x package names - Update MoneySerializer and MoneyDeserializer to implement contextual serialization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../jackson/datatype/jodamoney/JsonMoney.java | 2 +- .../datatype/jodamoney/MoneyDeserializer.java | 8 +-- .../datatype/jodamoney/MoneySerializer.java | 71 +++++++++++++++++++ .../MoneyFieldLevelRepresentationTest.java | 68 +++++++++--------- .../MoneyFormatShapeMappingTest.java | 34 ++++----- 5 files changed, 128 insertions(+), 55 deletions(-) diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java index ee82eb0a..143c4321 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java @@ -5,7 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import tools.jackson.annotation.JacksonAnnotation; +import com.fasterxml.jackson.annotation.JacksonAnnotation; /** * Annotation for configuring serialization and deserialization of {@link org.joda.money.Money Money} 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 025d4852..cd91661b 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,15 +4,14 @@ import java.util.Arrays; import java.util.Collection; -import tools.jackson.annotation.JsonFormat; +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.JsonDeserializer; -import tools.jackson.databind.deser.ContextualDeserializer; +import tools.jackson.databind.ValueDeserializer; import tools.jackson.databind.deser.std.StdDeserializer; import tools.jackson.databind.jsontype.TypeDeserializer; import tools.jackson.databind.type.LogicalType; @@ -23,7 +22,6 @@ import static java.util.Objects.requireNonNull; public class MoneyDeserializer extends StdDeserializer - implements ContextualDeserializer { private static final String F_AMOUNT = "amount"; private static final String F_CURRENCY = "currency"; @@ -41,7 +39,7 @@ public MoneyDeserializer() { } @Override - public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + public ValueDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { if (property == null) { return this; } 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 b2d989e6..12486ab4 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: @JsonMoney annotation + JsonMoney jsonMoney = property.getAnnotation(JsonMoney.class); + if (jsonMoney != null && jsonMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jsonMoney.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 index 3bc45690..d09749b7 100644 --- a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java +++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java @@ -1,8 +1,8 @@ package tools.jackson.datatype.jodamoney; -import tools.jackson.annotation.JsonCreator; -import tools.jackson.annotation.JsonFormat; -import tools.jackson.annotation.JsonProperty; +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; @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; @DisplayName("MoneyFieldLevelRepresentation tests") public class MoneyFieldLevelRepresentationTest extends ModuleTestBase { @@ -34,9 +34,9 @@ void fieldWithDecimalString() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); - assertThat(json).contains("\"fee\":{\"amount\":567"); - assertThat(json).contains("\"total\":{\"amount\":100.00"); + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); + assertTrue(json.contains("\"fee\":{\"amount\":567")); + assertTrue(json.contains("\"total\":{\"amount\":100.00")); } @Test @@ -52,9 +52,9 @@ void deserializeFieldWithDecimalString() throws Exception { PaymentWithFieldAnnotations payment = mapper.readValue(json, PaymentWithFieldAnnotations.class); // then - assertThat(payment.amount).isEqualTo(Money.parse("EUR 12.34")); - assertThat(payment.fee).isEqualTo(Money.parse("EUR 5.67")); - assertThat(payment.total).isEqualTo(Money.parse("EUR 100.00")); + 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 @@ -68,7 +68,7 @@ void getterWithAnnotation() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); } @Test @@ -82,7 +82,7 @@ void constructorParameterWithAnnotation() throws Exception { PaymentWithConstructorAnnotations payment = mapper.readValue(json, PaymentWithConstructorAnnotations.class); // then - assertThat(payment.getAmount()).isEqualTo(Money.parse("EUR 12.34")); + assertEquals(Money.parse("EUR 12.34"), payment.getAmount()); } } @@ -105,9 +105,9 @@ void fieldOverridesModuleDefault() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // override to string - assertThat(json).contains("\"fee\":{\"amount\":567"); // override to int - assertThat(json).contains("\"total\":{\"amount\":100.00"); // uses module default (number) + 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 @@ -121,7 +121,7 @@ void jsonMoneyWinsOverJsonFormat() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // @JsonMoney wins (STRING) + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JsonMoney wins (STRING) } @Test @@ -140,9 +140,9 @@ void roundTripWithMixedRepresentations() throws Exception { PaymentWithFieldAnnotations deserialized = mapper.readValue(json, PaymentWithFieldAnnotations.class); // then - assertThat(deserialized.amount).isEqualTo(original.amount); - assertThat(deserialized.fee).isEqualTo(original.fee); - assertThat(deserialized.total).isEqualTo(original.total); + assertEquals(original.amount, deserialized.amount); + assertEquals(original.fee, deserialized.fee); + assertEquals(original.total, deserialized.total); } } @@ -161,7 +161,7 @@ void defaultInheritsModuleConfig() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // inherits DECIMAL_STRING from module + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // inherits DECIMAL_STRING from module } } @@ -173,38 +173,41 @@ class MixinAnnotationTests { @DisplayName("should apply @JsonMoney from mix-in to override representation") void jsonMoneyMixinOverridesDefault() throws Exception { // setup - ObjectMapper mapper = mapperWithModule(); - mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class); + ObjectMapper mapper = mapperWithModuleBuilder() + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class) + .build(); PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); // when String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // mix-in applies DECIMAL_STRING + 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 = mapperWithModule(); - mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonFormat.class); + 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 - assertThat(json).contains("\"amount\":{\"amount\":1234"); // mix-in applies NUMBER_INT -> MINOR_CURRENCY_UNIT + 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 = mapperWithModule(); - mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class); + ObjectMapper mapper = mapperWithModuleBuilder() + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class) + .build(); PaymentWithoutAnnotations original = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); // when @@ -212,22 +215,23 @@ void roundTripWithMixin() throws Exception { PaymentWithoutAnnotations deserialized = mapper.readValue(json, PaymentWithoutAnnotations.class); // then - assertThat(deserialized.amount).isEqualTo(original.amount); + assertEquals(original.amount, deserialized.amount); } @Test @DisplayName("should apply @JsonMoney mix-in over @JsonFormat mix-in when both present") void jsonMoneyMixinWinsOverJsonFormatMixin() throws Exception { // setup - ObjectMapper mapper = mapperWithModule(); - mapper.addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithBothAnnotations.class); + 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 - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // @JsonMoney (STRING) wins over @JsonFormat (NUMBER_INT) + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JsonMoney (STRING) wins over @JsonFormat (NUMBER_INT) } } 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 index d6a0ab00..04cea614 100644 --- a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java +++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFormatShapeMappingTest.java @@ -1,15 +1,15 @@ package tools.jackson.datatype.jodamoney; -import tools.jackson.annotation.JsonCreator; -import tools.jackson.annotation.JsonFormat; -import tools.jackson.annotation.JsonProperty; +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.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; @DisplayName("MoneyFormatShapeMapping tests") public class MoneyFormatShapeMappingTest extends ModuleTestBase { @@ -29,7 +29,7 @@ void stringShapeToDecimalString() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); } @Test @@ -43,7 +43,7 @@ void numberShapeToDecimalNumber() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":12.34"); + assertTrue(json.contains("\"amount\":{\"amount\":12.34")); } @Test @@ -57,7 +57,7 @@ void numberFloatShapeToDecimalNumber() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":12.34"); + assertTrue(json.contains("\"amount\":{\"amount\":12.34")); } @Test @@ -71,7 +71,7 @@ void numberIntShapeToMinorCurrencyUnit() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":1234"); + assertTrue(json.contains("\"amount\":{\"amount\":1234")); } @Test @@ -85,7 +85,7 @@ void unsupportedShapeUsesDefault() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"amount\":{\"amount\":\"12.34\""); // uses module default + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // uses module default } } @@ -105,7 +105,7 @@ void roundTripStringShape() throws Exception { PaymentWithStringShape deserialized = mapper.readValue(json, PaymentWithStringShape.class); // then - assertThat(deserialized.amount).isEqualTo(original.amount); + assertEquals(original.amount, deserialized.amount); } @Test @@ -120,7 +120,7 @@ void roundTripNumberIntShape() throws Exception { PaymentWithNumberIntShape deserialized = mapper.readValue(json, PaymentWithNumberIntShape.class); // then - assertThat(deserialized.amount).isEqualTo(original.amount); + assertEquals(original.amount, deserialized.amount); } } @@ -143,9 +143,9 @@ void multipleFieldsWithDifferentShapes() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertThat(json).contains("\"stringAmount\":{\"amount\":\"12.34\""); - assertThat(json).contains("\"numberAmount\":{\"amount\":5.67"); - assertThat(json).contains("\"intAmount\":{\"amount\":10000"); + assertTrue(json.contains("\"stringAmount\":{\"amount\":\"12.34\"")); + assertTrue(json.contains("\"numberAmount\":{\"amount\":5.67")); + assertTrue(json.contains("\"intAmount\":{\"amount\":10000")); } @Test @@ -161,9 +161,9 @@ void deserializeMultipleFieldsWithDifferentShapes() throws Exception { PaymentWithMultipleShapes payment = mapper.readValue(json, PaymentWithMultipleShapes.class); // then - assertThat(payment.stringAmount).isEqualTo(Money.parse("EUR 12.34")); - assertThat(payment.numberAmount).isEqualTo(Money.parse("EUR 5.67")); - assertThat(payment.intAmount).isEqualTo(Money.parse("EUR 100.00")); + assertEquals(Money.parse("EUR 12.34"), payment.stringAmount); + assertEquals(Money.parse("EUR 5.67"), payment.numberAmount); + assertEquals(Money.parse("EUR 100.00"), payment.intAmount); } } From c00961d32a7658c6d52b61a5a95c585344b64aab Mon Sep 17 00:00:00 2001 From: Sri Adarsh Kumar Date: Wed, 22 Oct 2025 19:40:31 +0200 Subject: [PATCH 3/4] Rename JsonMoney annotation to JodaMoney MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed the @JsonMoney annotation to @JodaMoney for better alignment with the module naming convention. This change updates: - Annotation interface name from JsonMoney to JodaMoney - All references in serializer and deserializer classes - All test cases and documentation examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../{JsonMoney.java => JodaMoney.java} | 6 +-- .../datatype/jodamoney/MoneyDeserializer.java | 8 +-- .../datatype/jodamoney/MoneySerializer.java | 8 +-- .../MoneyFieldLevelRepresentationTest.java | 52 +++++++++---------- 4 files changed, 37 insertions(+), 37 deletions(-) rename joda-money/src/main/java/tools/jackson/datatype/jodamoney/{JsonMoney.java => JodaMoney.java} (91%) diff --git a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java similarity index 91% rename from joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java rename to joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java index 143c4321..17414ce0 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JsonMoney.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java @@ -18,10 +18,10 @@ * Example usage: *

  * public class Payment {
- *     @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
+ *     @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING)
  *     private Money amount;
  *
- *     @JsonMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT)
+ *     @JodaMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT)
  *     private Money fee;
  * }
  * 
@@ -32,7 +32,7 @@ @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotation -public @interface JsonMoney { +public @interface JodaMoney { /** * Specifies the amount representation to use for this property. 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 cd91661b..b3f115ff 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 @@ -60,10 +60,10 @@ public ValueDeserializer createContextual(DeserializationContext ctxt, BeanPr } private AmountRepresentation _resolveRepresentation(BeanProperty property, DeserializationContext ctxt) { - // Priority 1: @JsonMoney annotation - JsonMoney jsonMoney = property.getAnnotation(JsonMoney.class); - if (jsonMoney != null && jsonMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { - return jsonMoney.amountRepresentation(); + // Priority 1: @JodaMoney annotation + JodaMoney jodaMoney = property.getAnnotation(JodaMoney.class); + if (jodaMoney != null && jodaMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jodaMoney.amountRepresentation(); } // Priority 2: @JsonFormat mapping 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 12486ab4..dd7d471f 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 @@ -50,10 +50,10 @@ public ValueSerializer createContextual(SerializationContext ctxt, BeanProper } private AmountRepresentation _resolveRepresentation(BeanProperty property, SerializationContext ctxt) { - // Priority 1: @JsonMoney annotation - JsonMoney jsonMoney = property.getAnnotation(JsonMoney.class); - if (jsonMoney != null && jsonMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { - return jsonMoney.amountRepresentation(); + // Priority 1: @JodaMoney annotation + JodaMoney jodaMoney = property.getAnnotation(JodaMoney.class); + if (jodaMoney != null && jodaMoney.amountRepresentation() != AmountRepresentation.DEFAULT) { + return jodaMoney.amountRepresentation(); } // Priority 2: @JsonFormat mapping 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 index d09749b7..2d86fdb3 100644 --- a/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java +++ b/joda-money/src/test/java/tools/jackson/datatype/jodamoney/MoneyFieldLevelRepresentationTest.java @@ -16,11 +16,11 @@ public class MoneyFieldLevelRepresentationTest extends ModuleTestBase { @Nested - @DisplayName("@JsonMoney annotation tests") - class JsonMoneyAnnotationTests { + @DisplayName("@JodaMoney annotation tests") + class JodaMoneyAnnotationTests { @Test - @DisplayName("should serialize field with DECIMAL_STRING when @JsonMoney specified") + @DisplayName("should serialize field with DECIMAL_STRING when @JodaMoney specified") void fieldWithDecimalString() throws Exception { // setup ObjectMapper mapper = mapperWithModule(); @@ -40,7 +40,7 @@ void fieldWithDecimalString() throws Exception { } @Test - @DisplayName("should deserialize field with DECIMAL_STRING when @JsonMoney specified") + @DisplayName("should deserialize field with DECIMAL_STRING when @JodaMoney specified") void deserializeFieldWithDecimalString() throws Exception { // setup ObjectMapper mapper = mapperWithModule(); @@ -58,7 +58,7 @@ void deserializeFieldWithDecimalString() throws Exception { } @Test - @DisplayName("should serialize getter with @JsonMoney annotation") + @DisplayName("should serialize getter with @JodaMoney annotation") void getterWithAnnotation() throws Exception { // setup ObjectMapper mapper = mapperWithModule(); @@ -72,7 +72,7 @@ void getterWithAnnotation() throws Exception { } @Test - @DisplayName("should deserialize constructor parameter with @JsonMoney annotation") + @DisplayName("should deserialize constructor parameter with @JodaMoney annotation") void constructorParameterWithAnnotation() throws Exception { // setup ObjectMapper mapper = mapperWithModule(); @@ -111,8 +111,8 @@ void fieldOverridesModuleDefault() throws Exception { } @Test - @DisplayName("should use @JsonMoney over @JsonFormat when both present") - void jsonMoneyWinsOverJsonFormat() throws Exception { + @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")); @@ -121,7 +121,7 @@ void jsonMoneyWinsOverJsonFormat() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JsonMoney wins (STRING) + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JodaMoney wins (STRING) } @Test @@ -151,7 +151,7 @@ void roundTripWithMixedRepresentations() throws Exception { class DefaultRepresentationTests { @Test - @DisplayName("should inherit module default when @JsonMoney(DEFAULT) specified") + @DisplayName("should inherit module default when @JodaMoney(DEFAULT) specified") void defaultInheritsModuleConfig() throws Exception { // setup ObjectMapper mapper = mapperWithModule(m -> m.withAmountRepresentation(AmountRepresentation.DECIMAL_STRING)); @@ -170,11 +170,11 @@ void defaultInheritsModuleConfig() throws Exception { class MixinAnnotationTests { @Test - @DisplayName("should apply @JsonMoney from mix-in to override representation") - void jsonMoneyMixinOverridesDefault() throws Exception { + @DisplayName("should apply @JodaMoney from mix-in to override representation") + void jodaMoneyMixinOverridesDefault() throws Exception { // setup ObjectMapper mapper = mapperWithModuleBuilder() - .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class) + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJodaMoney.class) .build(); PaymentWithoutAnnotations payment = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); @@ -206,7 +206,7 @@ void jsonFormatMixinOverridesDefault() throws Exception { void roundTripWithMixin() throws Exception { // setup ObjectMapper mapper = mapperWithModuleBuilder() - .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJsonMoney.class) + .addMixIn(PaymentWithoutAnnotations.class, PaymentMixinWithJodaMoney.class) .build(); PaymentWithoutAnnotations original = new PaymentWithoutAnnotations(Money.parse("EUR 12.34")); @@ -219,8 +219,8 @@ void roundTripWithMixin() throws Exception { } @Test - @DisplayName("should apply @JsonMoney mix-in over @JsonFormat mix-in when both present") - void jsonMoneyMixinWinsOverJsonFormatMixin() throws Exception { + @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) @@ -231,17 +231,17 @@ void jsonMoneyMixinWinsOverJsonFormatMixin() throws Exception { String json = mapper.writeValueAsString(payment); // then - assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JsonMoney (STRING) wins over @JsonFormat (NUMBER_INT) + assertTrue(json.contains("\"amount\":{\"amount\":\"12.34\"")); // @JodaMoney (STRING) wins over @JsonFormat (NUMBER_INT) } } // Test POJOs static class PaymentWithFieldAnnotations { - @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) public Money amount; - @JsonMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT) + @JodaMoney(amountRepresentation = AmountRepresentation.MINOR_CURRENCY_UNIT) public Money fee; public Money total; // No annotation - uses module default @@ -265,7 +265,7 @@ public PaymentWithGetterAnnotations(Money amount) { this.amount = amount; } - @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) public Money getAmount() { return amount; } @@ -276,7 +276,7 @@ static class PaymentWithConstructorAnnotations { @JsonCreator public PaymentWithConstructorAnnotations( - @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) @JsonProperty("amount") Money amount ) { this.amount = amount; @@ -288,7 +288,7 @@ public Money getAmount() { } static class PaymentWithBothAnnotations { - @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) public Money amount; @@ -298,7 +298,7 @@ public PaymentWithBothAnnotations(Money amount) { } static class PaymentWithDefaultAnnotation { - @JsonMoney(amountRepresentation = AmountRepresentation.DEFAULT) + @JodaMoney(amountRepresentation = AmountRepresentation.DEFAULT) public Money amount; public PaymentWithDefaultAnnotation(Money amount) { @@ -319,8 +319,8 @@ public PaymentWithoutAnnotations() { } // Mix-in classes - abstract static class PaymentMixinWithJsonMoney { - @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + abstract static class PaymentMixinWithJodaMoney { + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) public Money amount; } @@ -330,7 +330,7 @@ abstract static class PaymentMixinWithJsonFormat { } abstract static class PaymentMixinWithBothAnnotations { - @JsonMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) + @JodaMoney(amountRepresentation = AmountRepresentation.DECIMAL_STRING) @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) public Money amount; } From a8e5f775161a68495b5c1ae030d1ded87ca62924 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 24 Oct 2025 18:21:25 -0700 Subject: [PATCH 4/4] Add release notes --- .../jodamoney/AmountRepresentation.java | 3 +++ .../jackson/datatype/jodamoney/JodaMoney.java | 2 ++ release-notes/CREDITS | 18 ++++++++++++++++++ release-notes/VERSION | 6 ++++++ 4 files changed, 29 insertions(+) create mode 100644 release-notes/CREDITS 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 3a566ef5..76626379 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 @@ -10,6 +10,9 @@ 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, 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 index 17414ce0..9c36122f 100644 --- a/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java +++ b/joda-money/src/main/java/tools/jackson/datatype/jodamoney/JodaMoney.java @@ -28,6 +28,8 @@ * * @see AmountRepresentation * @see JodaMoneyModule#withAmountRepresentation(AmountRepresentation) + * + * @since 3.1 */ @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) diff --git a/release-notes/CREDITS b/release-notes/CREDITS new file mode 100644 index 00000000..c1598704 --- /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 7d2f1cb3..fc8897ca 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