From 5a3454d9b067b7319b5279d2cbf28061c3f2789c Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Wed, 9 Sep 2026 10:12:47 +0100 Subject: [PATCH 1/5] (javax-money) Fail cleanly on non-Object input instead of throwing NPE `MonetaryAmountDeserializer.deserialize()` assumes it is positioned on a START_OBJECT and loops with: while (parser.nextToken() != JsonToken.END_OBJECT) { final String field = parser.currentName(); ... if (field.equals(names.getAmount())) { For any non-Object input `currentName()` returns `null`, so a bare `NullPointerException: Cannot invoke "String.equals(Object)" because "field" is null` escapes to the caller rather than a `JacksonException`. Inputs `12`, `"abc"` and `[1,2]` all reproduce it, and `moneta`'s `FastMoney`/`Money`/ `RoundedMoney` deserializers share this class. Verify the token up front (as joda-money's `MoneyDeserializer` already does), and only loop while positioned on a PROPERTY_NAME so malformed content cannot fall into the same trap. Also drop a duplicated import pair. Co-Authored-By: Claude Opus 5 (1M context) --- .../money/MonetaryAmountDeserializer.java | 16 +++- .../javax/money/FailOnNonObjectTest.java | 80 +++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java diff --git a/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java b/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java index d64d52b7..7509cfa5 100644 --- a/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java +++ b/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java @@ -6,9 +6,6 @@ import javax.money.CurrencyUnit; import javax.money.MonetaryAmount; -import javax.money.CurrencyUnit; -import javax.money.MonetaryAmount; - import tools.jackson.core.JsonParser; import tools.jackson.core.JsonToken; import tools.jackson.databind.DeserializationContext; @@ -39,10 +36,16 @@ public Object deserializeWithType(final JsonParser parser, final Deserialization @Override public M deserialize(final JsonParser parser, final DeserializationContext context) { + if (!parser.isExpectedStartObjectToken()) { + // 09-Sep-2026, pjfanning: Verify we got an Object; otherwise `currentName()` + // below returns `null` and we would fail with a bare NPE + return _handleNotObject(parser, context); + } + BigDecimal amount = null; CurrencyUnit currency = null; - while (parser.nextToken() != JsonToken.END_OBJECT) { + while (parser.nextToken() == JsonToken.PROPERTY_NAME) { final String field = parser.currentName(); parser.nextToken(); @@ -75,4 +78,9 @@ public M deserialize(final JsonParser parser, final DeserializationContext conte return context.reportPropertyInputMismatch(MonetaryAmount.class, missingName, String.format("Missing property: '%s'", missingName)); } + + @SuppressWarnings("unchecked") + private M _handleNotObject(final JsonParser parser, final DeserializationContext context) { + return (M) context.handleUnexpectedToken(MonetaryAmount.class, parser); + } } diff --git a/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java b/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java new file mode 100644 index 00000000..55b288b3 --- /dev/null +++ b/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java @@ -0,0 +1,80 @@ +package tools.jackson.datatype.javax.money; + +import java.util.List; + +import javax.money.MonetaryAmount; + +import tools.jackson.core.type.TypeReference; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests to verify that non-Object input is reported as a regular + * {@link MismatchedInputException}, and not as a bare {@link NullPointerException} + * coming out of {@code MonetaryAmountDeserializer}. + */ +public final class FailOnNonObjectTest { + + private final ObjectMapper unit = JsonMapper.builder() + .addModule(new JavaxMoneyModule()) + .build(); + + @Test + public void shouldFailOnNumber() { + final MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> unit.readValue("12", MonetaryAmount.class)); + assertThat(e.getMessage()).contains("javax.money.MonetaryAmount", "from Integer value"); + } + + @Test + public void shouldFailOnString() { + final MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> unit.readValue(a2q("'abc'"), MonetaryAmount.class)); + assertThat(e.getMessage()).contains("javax.money.MonetaryAmount", "from String value"); + } + + @Test + public void shouldFailOnArray() { + final MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> unit.readValue("[1,2]", MonetaryAmount.class)); + assertThat(e.getMessage()).contains("javax.money.MonetaryAmount", "from Array value"); + } + + @Test + public void shouldFailOnEmptyArray() { + assertThrows(MismatchedInputException.class, + () -> unit.readValue("[]", MonetaryAmount.class)); + } + + @Test + public void shouldFailOnBooleanWithinList() { + assertThrows(MismatchedInputException.class, + () -> unit.readValue(a2q("[{'amount':1,'currency':'EUR'},true]"), + new TypeReference>() { })); + } + + // ... while valid input keeps working + @Test + public void shouldStillDeserializeObject() { + final MonetaryAmount amount = unit.readValue(a2q("{'amount':29.95,'currency':'EUR'}"), + MonetaryAmount.class); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + assertThat(amount.getNumber().doubleValueExact()).isEqualTo(29.95); + } + + @Test + public void shouldStillDeserializeNull() { + assertThat((MonetaryAmount) unit.readValue("null", MonetaryAmount.class)).isNull(); + } + + private static String a2q(final String json) { + return json.replace("'", "\""); + } +} From 26732e78f9602f31046d54e08a77b790736b5fc4 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Wed, 9 Sep 2026 10:13:09 +0100 Subject: [PATCH 2/5] Add release notes entry for #91 Co-Authored-By: Claude Opus 5 (1M context) --- .../datatype/javax/money/MonetaryAmountDeserializer.java | 5 +++-- release-notes/VERSION | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java b/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java index 7509cfa5..0898bcc6 100644 --- a/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java +++ b/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java @@ -37,8 +37,9 @@ public Object deserializeWithType(final JsonParser parser, final Deserialization public M deserialize(final JsonParser parser, final DeserializationContext context) { if (!parser.isExpectedStartObjectToken()) { - // 09-Sep-2026, pjfanning: Verify we got an Object; otherwise `currentName()` - // below returns `null` and we would fail with a bare NPE + // 09-Sep-2026, pjfanning: [datatypes-misc#91] Verify we got an Object; + // otherwise `currentName()` below returns `null` and we would fail + // with a bare NPE return _handleNotObject(parser, context); } diff --git a/release-notes/VERSION b/release-notes/VERSION index 6c9ef325..b2929f6d 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -15,7 +15,8 @@ Modules: 3.3.0 (not yet released) -No changes since 3.2 +#91: (javax-money) `MonetaryAmountDeserializer` throws `NullPointerException` + for non-Object input 3.2.2 (14-Aug-2026) From b1bf871760f874936a3ddb4fa9fd3ca55aea2ad9 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 14 Sep 2026 20:05:15 -0700 Subject: [PATCH 3/5] ... --- release-notes/CREDITS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 9026d296..7bfa39b7 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -22,5 +22,8 @@ Christopher Smith (chrylis@github): author of `jakarta-mail` module (added in 2. * Contributed #90: (json-org) `JSONObject` deserialization quietly returns empty Object for non-Object input (3.3.0) +* Contributed #91: (javax-money) `MonetaryAmountDeserializer` throws `NullPointerException` + for non-Object input + (3.3.0) * Contributed #93: (moneta) `MonetaMoneyModule.getModuleName()` returns "JavaxMoneyModule" (3.3.0) From ac7a7980bf682901639e7183caa9215ee66acea5 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 14 Sep 2026 20:11:54 -0700 Subject: [PATCH 4/5] Minor fixes --- .../money/MonetaryAmountDeserializer.java | 19 ++++--- .../javax/money/FailOnNonObjectTest.java | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java b/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java index 0898bcc6..049958c6 100644 --- a/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java +++ b/javax-money/src/main/java/tools/jackson/datatype/javax/money/MonetaryAmountDeserializer.java @@ -36,17 +36,22 @@ public Object deserializeWithType(final JsonParser parser, final Deserialization @Override public M deserialize(final JsonParser parser, final DeserializationContext context) { - if (!parser.isExpectedStartObjectToken()) { - // 09-Sep-2026, pjfanning: [datatypes-misc#91] Verify we got an Object; - // otherwise `currentName()` below returns `null` and we would fail - // with a bare NPE + // 09-Sep-2026, pjfanning: [datatypes-misc#91] Verify we got an Object; + // otherwise `currentName()` below returns `null` and we would fail + // with a bare NPE. Besides START_OBJECT, also accept being positioned + // within Object contents (PROPERTY_NAME or END_OBJECT), as may happen + // when caller has already consumed START_OBJECT (and possibly properties) + JsonToken t = parser.currentToken(); + if (t == JsonToken.START_OBJECT) { + t = parser.nextToken(); + } else if (t != JsonToken.PROPERTY_NAME && t != JsonToken.END_OBJECT) { return _handleNotObject(parser, context); } BigDecimal amount = null; CurrencyUnit currency = null; - while (parser.nextToken() == JsonToken.PROPERTY_NAME) { + for (; t == JsonToken.PROPERTY_NAME; t = parser.nextToken()) { final String field = parser.currentName(); parser.nextToken(); @@ -56,8 +61,8 @@ public M deserialize(final JsonParser parser, final DeserializationContext conte } else if (field.equals(names.getCurrency())) { currency = context.readValue(parser, CurrencyUnit.class); } else if (field.equals(names.getFormatted())) { - //noinspection UnnecessaryContinue - continue; + // [datatypes-misc#91] Skip whole value, which may be structured + parser.skipChildren(); } else if (context.isEnabled(FAIL_ON_UNKNOWN_PROPERTIES)) { throw UnrecognizedPropertyException.from(parser, MonetaryAmount.class, field, Arrays.asList(names.getAmount(), names.getCurrency(), names.getFormatted())); diff --git a/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java b/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java index 55b288b3..afef61fe 100644 --- a/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java +++ b/javax-money/src/test/java/tools/jackson/datatype/javax/money/FailOnNonObjectTest.java @@ -4,9 +4,13 @@ import javax.money.MonetaryAmount; +import tools.jackson.core.JsonParser; import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.json.JsonMapper; @@ -69,11 +73,61 @@ public void shouldStillDeserializeObject() { assertThat(amount.getNumber().doubleValueExact()).isEqualTo(29.95); } + @Test + public void shouldSkipStructuredFormatted() { + final MonetaryAmount first = unit.readValue( + a2q("{'formatted':[1,2],'amount':1,'currency':'EUR'}"), MonetaryAmount.class); + assertThat(first.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + + final MonetaryAmount last = unit.readValue( + a2q("{'amount':1,'currency':'EUR','formatted':{'x':[1,{'y':2}]}}"), MonetaryAmount.class); + assertThat(last.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + + final List list = unit.readValue( + a2q("[{'amount':1,'currency':'EUR','formatted':[1,2]},{'amount':2,'currency':'USD'}]"), + new TypeReference>() { }); + assertThat(list).hasSize(2); + assertThat(list.get(1).getCurrency().getCurrencyCode()).isEqualTo("USD"); + } + + // Deserializer may also be called when START_OBJECT has already been consumed + @Test + public void shouldDeserializeStartingFromPropertyName() { + final AmountWrapper w = unit.readValue(a2q("{'amount':29.95,'currency':'EUR'}"), + AmountWrapper.class); + assertThat(w.amount.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + assertThat(w.amount.getNumber().doubleValueExact()).isEqualTo(29.95); + } + + @Test + public void shouldReportMissingPropertiesStartingFromEndObject() { + final MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> unit.readValue("{}", AmountWrapper.class)); + assertThat(e.getMessage()).contains("Missing property"); + } + @Test public void shouldStillDeserializeNull() { assertThat((MonetaryAmount) unit.readValue("null", MonetaryAmount.class)).isNull(); } + @JsonDeserialize(using = AmountWrapperDeserializer.class) + static final class AmountWrapper { + MonetaryAmount amount; + } + + // Skips START_OBJECT and delegates Object contents to MonetaryAmount deserializer + public static final class AmountWrapperDeserializer extends ValueDeserializer { + @Override + public AmountWrapper deserialize(final JsonParser p, final DeserializationContext ctxt) { + p.nextToken(); + final AmountWrapper w = new AmountWrapper(); + w.amount = (MonetaryAmount) ctxt.findRootValueDeserializer( + ctxt.constructType(MonetaryAmount.class)).deserialize(p, ctxt); + return w; + } + } + private static String a2q(final String json) { return json.replace("'", "\""); } From fdfde31f22863216e334836ca25ab9da1655fe33 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 14 Sep 2026 20:15:52 -0700 Subject: [PATCH 5/5] Further fixing --- .../javax/money/CurrencyUnitDeserializer.java | 18 ++++++++-- .../money/CurrencyUnitDeserializerTest.java | 35 +++++++++++++++++-- .../moneta/CurrencyUnitDeserializerTest.java | 5 +-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/javax-money/src/main/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializer.java b/javax-money/src/main/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializer.java index 6458cb98..4b03c60e 100644 --- a/javax-money/src/main/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializer.java +++ b/javax-money/src/main/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializer.java @@ -2,8 +2,10 @@ import javax.money.CurrencyUnit; import javax.money.Monetary; +import javax.money.UnknownCurrencyException; import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.deser.std.StdScalarDeserializer; import tools.jackson.databind.jsontype.TypeDeserializer; @@ -29,7 +31,19 @@ public Object deserializeWithType(final JsonParser parser, final Deserialization @Override public CurrencyUnit deserialize(final JsonParser parser, final DeserializationContext context) { - final String currencyCode = parser.getValueAsString(); - return Monetary.getCurrency(currencyCode); + // [datatypes-misc#91] Only accept String values: for other tokens + // `getValueAsString()` returns `null` (leading to bare NPE) or + // coerces scalars (like numbers) into bogus currency codes + if (!parser.hasToken(JsonToken.VALUE_STRING)) { + return (CurrencyUnit) context.handleUnexpectedToken(getValueType(context), parser); + } + final String currencyCode = parser.getString(); + try { + return Monetary.getCurrency(currencyCode); + } catch (UnknownCurrencyException e) { + // [datatypes-misc#91] Report as regular Jackson exception + return (CurrencyUnit) context.handleWeirdStringValue(handledType(), currencyCode, + "not a valid currency code"); + } } } diff --git a/javax-money/src/test/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializerTest.java b/javax-money/src/test/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializerTest.java index fa1edab8..736ae639 100644 --- a/javax-money/src/test/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializerTest.java +++ b/javax-money/src/test/java/tools/jackson/datatype/javax/money/CurrencyUnitDeserializerTest.java @@ -1,12 +1,14 @@ package tools.jackson.datatype.javax.money; import javax.money.CurrencyUnit; -import javax.money.UnknownCurrencyException; +import javax.money.MonetaryAmount; import org.javamoney.moneta.CurrencyUnitBuilder; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; @@ -27,8 +29,37 @@ public void shouldDeserialize() throws Exception { @Test public void shouldNotDeserializeInvalidCurrency() { - assertThrows(UnknownCurrencyException.class, () -> + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> unit.readValue("\"FOO\"", CurrencyUnit.class)); + assertThat(e.getMessage()).contains("javax.money.CurrencyUnit", "\"FOO\"", + "not a valid currency code"); + assertThat(e.getValue()).isEqualTo("FOO"); + } + + @Test + public void shouldNotDeserializeInvalidCurrencyWithinAmount() { + assertThrows(InvalidFormatException.class, () -> + unit.readValue("{\"amount\":1,\"currency\":\"FOO\"}", MonetaryAmount.class)); + } + + // [datatypes-misc#91] Non-String input must fail with Jackson exception, not NPE + @Test + public void shouldFailOnNonStringInput() { + for (String json : new String[] { "12", "true", "{}", "{\"x\":1}", "[]", "[\"EUR\"]" }) { + final MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> unit.readValue(json, CurrencyUnit.class), json); + assertThat(e.getMessage()).contains("javax.money.CurrencyUnit"); + } + } + + @Test + public void shouldFailOnNonStringInputWithinAmount() { + for (String currency : new String[] { "1", "{\"x\":1}", "[\"EUR\"]" }) { + final String json = "{\"amount\":1,\"currency\":" + currency + "}"; + final MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> unit.readValue(json, MonetaryAmount.class), json); + assertThat(e.getMessage()).contains("javax.money.CurrencyUnit"); + } } @Test diff --git a/moneta/src/test/java/tools/jackson/datatype/moneta/CurrencyUnitDeserializerTest.java b/moneta/src/test/java/tools/jackson/datatype/moneta/CurrencyUnitDeserializerTest.java index 9d249fd5..c4aaa894 100644 --- a/moneta/src/test/java/tools/jackson/datatype/moneta/CurrencyUnitDeserializerTest.java +++ b/moneta/src/test/java/tools/jackson/datatype/moneta/CurrencyUnitDeserializerTest.java @@ -1,11 +1,11 @@ package tools.jackson.datatype.moneta; import javax.money.CurrencyUnit; -import javax.money.UnknownCurrencyException; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.InvalidFormatException; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; @@ -28,8 +28,9 @@ public void shouldDeserialize() throws Exception { @Test public void shouldNotDeserializeInvalidCurrency() { - assertThrows(UnknownCurrencyException.class, () -> + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> unit.readValue("\"FOO\"", CurrencyUnit.class)); + assertThat(e.getValue()).isEqualTo("FOO"); } @Test