From 965fafec445cac5f56ae40aa27f8d7d94b703495 Mon Sep 17 00:00:00 2001 From: GoldyL Date: Tue, 9 Jun 2026 15:26:13 +0200 Subject: [PATCH 1/4] Fixed malformed numbers leaking a NumberFormatException from the JSON parser (#31) parseNumber now validates the candidate token without consuming it and returns null on failure, so the existing error path reports a proper ParseException at the offending position instead of leaking a NumberFormatException. Also fixed exponent-only numbers (e.g. 1e3) being routed to Long.parseLong and failing. --- .../abstractdata/json/JsonParser.java | 30 +++++++++++++------ .../abstractdata/json/JsonParserTest.java | 23 ++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java b/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java index 980a7f0..31d3b64 100644 --- a/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java +++ b/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java @@ -111,17 +111,29 @@ private void popWhitespace(Deque stack) { private AbstractPrimitive parseNumber(Deque stack) { StringBuilder sb = new StringBuilder(); - while (stack.peek() != null && (Character.isDigit(stack.peek()) || stack.peek() == '.' || stack.peek() == '+' || stack.peek() == '-' || stack.peek() == 'E' || stack.peek() == 'e')) - sb.append(stack.pop()); + for (char c : stack) { + if (Character.isDigit(c) || c == '.' || c == '+' || c == '-' || c == 'E' || c == 'e') + sb.append(c); + else + break; + } String s = sb.toString(); - if (s.contains(".")) { - return new AbstractPrimitive(Double.parseDouble(s)); - } else { - long l = Long.parseLong(s); - if (l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) - return new AbstractPrimitive((int) l); - return new AbstractPrimitive(l); + AbstractPrimitive result; + try { + if (s.contains(".") || s.contains("e") || s.contains("E")) { + result = new AbstractPrimitive(Double.parseDouble(s)); + } else { + long l = Long.parseLong(s); + result = l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE ? new AbstractPrimitive((int) l) : new AbstractPrimitive(l); + } + } catch (NumberFormatException e) { + // Not actually a valid number: leave the characters on the stack so the + // caller reports a proper ParseException at the offending position. + return null; } + for (int i = 0; i < s.length(); i++) + stack.pop(); + return result; } private AbstractPrimitive parseString(Deque stack) { diff --git a/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java b/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java index 5c96faa..bd790e9 100644 --- a/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java +++ b/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java @@ -91,6 +91,29 @@ public void testParseDouble() { assertEquals(123.456, e.number()); } + @Test + public void testParseScientificNotation() { + AbstractElement e = assertDoesNotThrow(() -> new JsonParser().parse("1e3")); + assertTrue(e.isNumber()); + assertEquals(1000.0, e.number()); + e = assertDoesNotThrow(() -> new JsonParser().parse("2.5E-2")); + assertTrue(e.isNumber()); + assertEquals(0.025, e.number()); + } + + @Test + public void testParseMalformedNumber() { + // See issue #31: malformed numbers must fail fast with a ParseException + // instead of leaking a NumberFormatException. + ParseException e = assertThrows(ParseException.class, () -> new JsonParser().parse("--")); + assertEquals("Unexpected character '-' at line 1 pos 1", e.getMessage()); + assertThrows(ParseException.class, () -> new JsonParser().parse("-")); + assertThrows(ParseException.class, () -> new JsonParser().parse("1.2.3")); + assertThrows(ParseException.class, () -> new JsonParser().parse("12e")); + assertThrows(ParseException.class, () -> new JsonParser().parse(".")); + assertThrows(ParseException.class, () -> new JsonParser().parse("[--]")); + } + @Test public void testParseStringEscapeSeq() { Map escapes = new HashMap<>(); From 8b40a37e423cb84a2400f8b393c661bfc8e8e105 Mon Sep 17 00:00:00 2001 From: GoldyL Date: Tue, 9 Jun 2026 16:00:26 +0200 Subject: [PATCH 2/4] Added java.time support to the object mapper (#23) New JavaTimeMapper handles LocalDate, LocalDateTime, LocalTime, Instant, OffsetDateTime, ZonedDateTime, OffsetTime, Year, YearMonth and MonthDay. Values serialize to their canonical ISO-8601 form by default; @DateFormat still applies -- a custom pattern (DateTimeFormatter syntax) or epoch mode (Instant only, otherwise a MapperException). Registered as a sibling of DateMapper, no public API or Java language level change. --- .../abstractdata/mapper/DefaultMappers.java | 100 +++++++++++++++++- .../mapper/JavaTimeMapperTest.java | 95 +++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java diff --git a/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java b/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java index 0a7a682..ef205d6 100644 --- a/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java +++ b/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java @@ -12,6 +12,9 @@ import java.sql.Timestamp; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; import java.util.*; import java.util.concurrent.*; import java.util.stream.Collectors; @@ -22,6 +25,7 @@ public final class DefaultMappers { public static final CollectionMapper COLLECTION = new CollectionMapper(); public static final MapMapper MAP = new MapMapper(); public static final DateMapper DATE = new DateMapper(); + public static final JavaTimeMapper JAVA_TIME = new JavaTimeMapper(); public static final AbstractMapper ABSTRACT = new AbstractMapper(); public static final FallbackMapper FALLBACK = new FallbackMapper(); @@ -34,7 +38,8 @@ public static Map, MapperTypeAdapter> create() { PRIMITIVE, COLLECTION, MAP, - DATE + DATE, + JAVA_TIME }) { for (Class type : adapter.getSupportedTypes()) map.put(type, adapter); @@ -298,6 +303,99 @@ public Class[] getSupportedTypes() { } + public static final class JavaTimeMapper implements MapperTypeAdapter { + + private JavaTimeMapper() { + } + + public AbstractElement toAbstract(MapperContext context, Object value) throws MapperException { + if (!(value instanceof TemporalAccessor)) + return null; + DateFormat df = context.getAnnotation(DateFormat.class); + try { + if (df != null && df.epoch()) { + if (!(value instanceof Instant)) + throw new MapperException("@DateFormat epoch mode is only supported for Instant, not '" + value.getClass().getName() + "'"); + Instant instant = (Instant) value; + return new AbstractPrimitive(df.millis() ? instant.toEpochMilli() : instant.getEpochSecond()); + } + if (df != null && df.value().length() > 0) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(df.value()); + if (df.timezone().length() > 0) + formatter = formatter.withZone(ZoneId.of(df.timezone())); + else if (value instanceof Instant) + formatter = formatter.withZone(ZoneOffset.UTC); + return new AbstractPrimitive(formatter.format((TemporalAccessor) value)); + } + // Each supported type's toString() is its canonical ISO-8601 form and round-trips through its own parse(). + return new AbstractPrimitive(value.toString()); + } catch (DateTimeException | IllegalArgumentException ex) { + throw new MapperException("Failed to format date" + (context.getField() != null ? " for field '" + context.getFieldName() + "'" : "") + ": " + ex.getMessage()); + } + } + + public Object fromAbstract(MapperContext context, AbstractElement element, Class type) throws MapperException { + DateFormat df = context.getAnnotation(DateFormat.class); + try { + if (df != null && df.epoch()) { + if (!type.equals(Instant.class)) + throw new MapperException("@DateFormat epoch mode is only supported for Instant, not '" + type.getName() + "'"); + long time = element.number(context.getMapper().isStrict()).longValue(); + return df.millis() ? Instant.ofEpochMilli(time) : Instant.ofEpochSecond(time); + } + DateTimeFormatter formatter = null; + if (df != null && df.value().length() > 0) { + formatter = DateTimeFormatter.ofPattern(df.value()); + if (df.timezone().length() > 0) + formatter = formatter.withZone(ZoneId.of(df.timezone())); + } + return parse(type, element.string(context.getMapper().isStrict()), formatter); + } catch (DateTimeException | IllegalArgumentException | AbstractCoercingException ex) { + throw new MapperException("Failed to parse date '" + element.string() + "'" + (context.getField() != null ? " for field '" + context.getFieldName() + "'" : "")); + } + } + + private Object parse(Class type, String s, DateTimeFormatter formatter) throws MapperException { + if (type.equals(LocalDate.class)) + return formatter == null ? LocalDate.parse(s) : LocalDate.parse(s, formatter); + if (type.equals(LocalDateTime.class)) + return formatter == null ? LocalDateTime.parse(s) : LocalDateTime.parse(s, formatter); + if (type.equals(LocalTime.class)) + return formatter == null ? LocalTime.parse(s) : LocalTime.parse(s, formatter); + if (type.equals(Instant.class)) + return formatter == null ? Instant.parse(s) : formatter.parse(s, Instant::from); + if (type.equals(OffsetDateTime.class)) + return formatter == null ? OffsetDateTime.parse(s) : OffsetDateTime.parse(s, formatter); + if (type.equals(ZonedDateTime.class)) + return formatter == null ? ZonedDateTime.parse(s) : ZonedDateTime.parse(s, formatter); + if (type.equals(OffsetTime.class)) + return formatter == null ? OffsetTime.parse(s) : OffsetTime.parse(s, formatter); + if (type.equals(Year.class)) + return formatter == null ? Year.parse(s) : Year.parse(s, formatter); + if (type.equals(YearMonth.class)) + return formatter == null ? YearMonth.parse(s) : YearMonth.parse(s, formatter); + if (type.equals(MonthDay.class)) + return formatter == null ? MonthDay.parse(s) : MonthDay.parse(s, formatter); + throw new MapperException("Unsupported java.time type '" + type.getName() + "'"); + } + + public Class[] getSupportedTypes() { + return new Class[]{ + LocalDate.class, + LocalDateTime.class, + LocalTime.class, + Instant.class, + OffsetDateTime.class, + ZonedDateTime.class, + OffsetTime.class, + Year.class, + YearMonth.class, + MonthDay.class + }; + } + + } + public static final class AbstractMapper implements MapperTypeAdapter { private AbstractMapper() { diff --git a/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java b/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java new file mode 100644 index 0000000..c6cf0b1 --- /dev/null +++ b/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java @@ -0,0 +1,95 @@ +package org.javawebstack.abstractdata.mapper; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.mapper.annotation.DateFormat; +import org.javawebstack.abstractdata.mapper.exception.MapperException; +import org.junit.jupiter.api.Test; + +import java.time.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class JavaTimeMapperTest { + + @Test + public void testIsoRoundTripTopLevel() { + assertRoundTrip(LocalDate.of(2026, 6, 9), LocalDate.class, "2026-06-09"); + assertRoundTrip(LocalDateTime.of(2026, 6, 9, 15, 30, 45), LocalDateTime.class, "2026-06-09T15:30:45"); + assertRoundTrip(LocalTime.of(15, 30, 45), LocalTime.class, "15:30:45"); + assertRoundTrip(Instant.parse("2026-06-09T13:50:45Z"), Instant.class, "2026-06-09T13:50:45Z"); + assertRoundTrip(OffsetDateTime.of(2026, 6, 9, 15, 30, 45, 0, ZoneOffset.ofHours(2)), OffsetDateTime.class, "2026-06-09T15:30:45+02:00"); + assertRoundTrip(ZonedDateTime.of(2026, 6, 9, 15, 30, 45, 0, ZoneId.of("Europe/Berlin")), ZonedDateTime.class, "2026-06-09T15:30:45+02:00[Europe/Berlin]"); + assertRoundTrip(OffsetTime.of(15, 30, 45, 0, ZoneOffset.ofHours(2)), OffsetTime.class, "15:30:45+02:00"); + assertRoundTrip(Year.of(2026), Year.class, "2026"); + assertRoundTrip(YearMonth.of(2026, 6), YearMonth.class, "2026-06"); + assertRoundTrip(MonthDay.of(6, 9), MonthDay.class, "--06-09"); + } + + private void assertRoundTrip(T value, Class type, String expected) { + Mapper mapper = new Mapper(); + AbstractElement element = assertDoesNotThrow(() -> mapper.map(value)); + assertTrue(element.isString(), type.getSimpleName() + " should serialize to a string"); + assertEquals(expected, element.string()); + assertEquals(value, assertDoesNotThrow(() -> mapper.map(element, type))); + } + + static class EpochHolder { + @DateFormat(epoch = true) + Instant seconds; + @DateFormat(epoch = true, millis = true) + Instant millis; + } + + @Test + public void testEpochInstant() { + EpochHolder holder = new EpochHolder(); + holder.seconds = Instant.ofEpochSecond(1781013045); + holder.millis = Instant.ofEpochMilli(1781013045123L); + + AbstractElement element = assertDoesNotThrow(() -> new Mapper().map(holder)); + assertEquals(1781013045L, element.object().get("seconds").number().longValue()); + assertEquals(1781013045123L, element.object().get("millis").number().longValue()); + + EpochHolder parsed = assertDoesNotThrow(() -> new Mapper().map(element, EpochHolder.class)); + assertEquals(Instant.ofEpochSecond(1781013045), parsed.seconds); + assertEquals(Instant.ofEpochMilli(1781013045123L), parsed.millis); + } + + static class PatternHolder { + @DateFormat("dd.MM.yyyy") + LocalDate day; + @DateFormat("yyyy-MM-dd HH:mm:ss") + LocalDateTime moment; + } + + @Test + public void testCustomPattern() { + PatternHolder holder = new PatternHolder(); + holder.day = LocalDate.of(2026, 6, 9); + holder.moment = LocalDateTime.of(2026, 6, 9, 15, 30, 45); + + AbstractElement element = assertDoesNotThrow(() -> new Mapper().map(holder)); + assertEquals("09.06.2026", element.object().get("day").string()); + assertEquals("2026-06-09 15:30:45", element.object().get("moment").string()); + + PatternHolder parsed = assertDoesNotThrow(() -> new Mapper().map(element, PatternHolder.class)); + assertEquals(holder.day, parsed.day); + assertEquals(holder.moment, parsed.moment); + } + + static class BadEpochHolder { + @DateFormat(epoch = true) + LocalDate day = LocalDate.of(2026, 6, 9); + } + + @Test + public void testEpochRejectedForNonInstant() { + MapperException e = assertThrows(MapperException.class, () -> new Mapper().map(new BadEpochHolder())); + assertTrue(e.getMessage().contains("epoch")); + } + + @Test + public void testMalformedValueThrowsMapperException() { + assertThrows(MapperException.class, () -> new Mapper().map(new org.javawebstack.abstractdata.AbstractPrimitive("not-a-date"), LocalDate.class)); + } +} From f20bcb635070e093f689180688e830aaac7fabb2 Mon Sep 17 00:00:00 2001 From: GoldyL Date: Thu, 25 Jun 2026 22:27:06 +0200 Subject: [PATCH 3/4] Fix JavaTimeMapper round-trip for Instant patterns and non-primitive input fromAbstract rebuilt its failure message with element.string(), which itself throws AbstractCoercingException for object/array input and escaped the intended MapperException; a safe representation is now captured before the try and reused in the catch. A custom @DateFormat pattern on an Instant was written through a UTC-defaulted formatter but read back through a zone-less one, so deserializing the value it had just serialized threw; the read side now applies the same UTC default. Add round-trip coverage for Instant custom patterns and for non-primitive input. --- .../abstractdata/mapper/DefaultMappers.java | 5 ++++- .../mapper/JavaTimeMapperTest.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java b/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java index ef205d6..5e686e2 100644 --- a/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java +++ b/src/main/java/org/javawebstack/abstractdata/mapper/DefaultMappers.java @@ -336,6 +336,7 @@ else if (value instanceof Instant) public Object fromAbstract(MapperContext context, AbstractElement element, Class type) throws MapperException { DateFormat df = context.getAnnotation(DateFormat.class); + String raw = element.isPrimitive() ? element.string() : element.toJsonString(); try { if (df != null && df.epoch()) { if (!type.equals(Instant.class)) @@ -348,10 +349,12 @@ public Object fromAbstract(MapperContext context, AbstractElement element, Class formatter = DateTimeFormatter.ofPattern(df.value()); if (df.timezone().length() > 0) formatter = formatter.withZone(ZoneId.of(df.timezone())); + else if (type.equals(Instant.class)) + formatter = formatter.withZone(ZoneOffset.UTC); } return parse(type, element.string(context.getMapper().isStrict()), formatter); } catch (DateTimeException | IllegalArgumentException | AbstractCoercingException ex) { - throw new MapperException("Failed to parse date '" + element.string() + "'" + (context.getField() != null ? " for field '" + context.getFieldName() + "'" : "")); + throw new MapperException("Failed to parse date '" + raw + "'" + (context.getField() != null ? " for field '" + context.getFieldName() + "'" : "")); } } diff --git a/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java b/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java index c6cf0b1..c2c4199 100644 --- a/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java +++ b/src/test/java/org/javawebstack/abstractdata/mapper/JavaTimeMapperTest.java @@ -92,4 +92,26 @@ public void testEpochRejectedForNonInstant() { public void testMalformedValueThrowsMapperException() { assertThrows(MapperException.class, () -> new Mapper().map(new org.javawebstack.abstractdata.AbstractPrimitive("not-a-date"), LocalDate.class)); } + + @Test + public void testNonPrimitiveValueThrowsMapperException() { + assertThrows(MapperException.class, () -> new Mapper().map(new org.javawebstack.abstractdata.AbstractObject(), LocalDate.class)); + } + + static class InstantPatternHolder { + @DateFormat("yyyy-MM-dd'T'HH:mm:ss") + Instant ts; + } + + @Test + public void testCustomPatternInstantRoundTrip() { + InstantPatternHolder holder = new InstantPatternHolder(); + holder.ts = Instant.parse("2026-06-09T13:50:45Z"); + + AbstractElement element = assertDoesNotThrow(() -> new Mapper().map(holder)); + assertEquals("2026-06-09T13:50:45", element.object().get("ts").string()); + + InstantPatternHolder parsed = assertDoesNotThrow(() -> new Mapper().map(element, InstantPatternHolder.class)); + assertEquals(holder.ts, parsed.ts); + } } From 1f66d3f0b37fe936622cd3b7b2909a1b80821844 Mon Sep 17 00:00:00 2001 From: GoldyL Date: Thu, 25 Jun 2026 23:49:31 +0200 Subject: [PATCH 4/4] Report malformed JSON numbers at the offending character parseNumber rejected an invalid token without consuming anything, so parse() flagged the error at the token start ('1' for "1.2.3") rather than at the character that actually broke parsing. It now consumes the longest valid numeric prefix before returning null, leaving the offending character as the stack head for the position report. The token is still rejected as a whole; the shared parse logic moved into toNumber(). --- .../abstractdata/json/JsonParser.java | 36 +++++++++++++------ .../abstractdata/json/JsonParserTest.java | 10 ++++-- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java b/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java index 31d3b64..6f8aa96 100644 --- a/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java +++ b/src/main/java/org/javawebstack/abstractdata/json/JsonParser.java @@ -118,17 +118,20 @@ private AbstractPrimitive parseNumber(Deque stack) { break; } String s = sb.toString(); - AbstractPrimitive result; - try { - if (s.contains(".") || s.contains("e") || s.contains("E")) { - result = new AbstractPrimitive(Double.parseDouble(s)); - } else { - long l = Long.parseLong(s); - result = l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE ? new AbstractPrimitive((int) l) : new AbstractPrimitive(l); + AbstractPrimitive result = toNumber(s); + if (result == null) { + // Malformed token (e.g. "1.2.3", "12e"): consume the longest valid numeric prefix + // so the caller reports the ParseException at the offending character instead of the + // token start. The token is still rejected as a whole -- no partial number is accepted. + int valid = 0; + for (int i = s.length() - 1; i > 0; i--) { + if (toNumber(s.substring(0, i)) != null) { + valid = i; + break; + } } - } catch (NumberFormatException e) { - // Not actually a valid number: leave the characters on the stack so the - // caller reports a proper ParseException at the offending position. + for (int i = 0; i < valid; i++) + stack.pop(); return null; } for (int i = 0; i < s.length(); i++) @@ -136,6 +139,19 @@ private AbstractPrimitive parseNumber(Deque stack) { return result; } + private AbstractPrimitive toNumber(String s) { + if (s.isEmpty()) + return null; + try { + if (s.contains(".") || s.contains("e") || s.contains("E")) + return new AbstractPrimitive(Double.parseDouble(s)); + long l = Long.parseLong(s); + return l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE ? new AbstractPrimitive((int) l) : new AbstractPrimitive(l); + } catch (NumberFormatException e) { + return null; + } + } + private AbstractPrimitive parseString(Deque stack) { stack.pop(); StringBuilder sb = new StringBuilder(); diff --git a/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java b/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java index bd790e9..b68e386 100644 --- a/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java +++ b/src/test/java/org/javawebstack/abstractdata/json/JsonParserTest.java @@ -108,10 +108,16 @@ public void testParseMalformedNumber() { ParseException e = assertThrows(ParseException.class, () -> new JsonParser().parse("--")); assertEquals("Unexpected character '-' at line 1 pos 1", e.getMessage()); assertThrows(ParseException.class, () -> new JsonParser().parse("-")); - assertThrows(ParseException.class, () -> new JsonParser().parse("1.2.3")); - assertThrows(ParseException.class, () -> new JsonParser().parse("12e")); assertThrows(ParseException.class, () -> new JsonParser().parse(".")); assertThrows(ParseException.class, () -> new JsonParser().parse("[--]")); + + // The error points at the offending character within the token, not the token start. + e = assertThrows(ParseException.class, () -> new JsonParser().parse("1.2.3")); + assertEquals("Unexpected character '.' at line 1 pos 4", e.getMessage()); + assertEquals(3, e.getErrorOffset()); + e = assertThrows(ParseException.class, () -> new JsonParser().parse("12e")); + assertEquals("Unexpected character 'e' at line 1 pos 3", e.getMessage()); + assertEquals(2, e.getErrorOffset()); } @Test