From 965fafec445cac5f56ae40aa27f8d7d94b703495 Mon Sep 17 00:00:00 2001 From: GoldyL Date: Tue, 9 Jun 2026 15:26:13 +0200 Subject: [PATCH 1/2] 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/2] 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)); + } +}