Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions src/main/java/org/javawebstack/abstractdata/json/JsonParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,29 @@ private void popWhitespace(Deque<Character> stack) {

private AbstractPrimitive parseNumber(Deque<Character> 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<Character> stack) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -34,7 +38,8 @@ public static Map<Class<?>, MapperTypeAdapter> create() {
PRIMITIVE,
COLLECTION,
MAP,
DATE
DATE,
JAVA_TIME
}) {
for (Class<?> type : adapter.getSupportedTypes())
map.put(type, adapter);
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> escapes = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <T> void assertRoundTrip(T value, Class<T> 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));
}
}