Skip to content

Commit dbaad15

Browse files
committed
code cleanup: remove type-safe from parsing duration/period
1 parent 967725f commit dbaad15

File tree

2 files changed

+190
-12
lines changed

2 files changed

+190
-12
lines changed

jooby/src/main/java/io/jooby/internal/converter/BuiltinConverter.java

Lines changed: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
*/
66
package io.jooby.internal.converter;
77

8+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
9+
import static java.util.concurrent.TimeUnit.NANOSECONDS;
10+
811
import java.math.BigDecimal;
912
import java.math.BigInteger;
1013
import java.net.MalformedURLException;
@@ -15,14 +18,12 @@
1518
import java.time.*;
1619
import java.time.format.DateTimeFormatter;
1720
import java.time.format.DateTimeParseException;
21+
import java.time.temporal.ChronoUnit;
1822
import java.util.Date;
1923
import java.util.TimeZone;
2024
import java.util.UUID;
2125
import java.util.concurrent.TimeUnit;
2226

23-
import com.typesafe.config.ConfigException;
24-
import com.typesafe.config.ConfigFactory;
25-
import com.typesafe.config.ConfigValueFactory;
2627
import edu.umd.cs.findbugs.annotations.NonNull;
2728
import io.jooby.SneakyThrows;
2829
import io.jooby.StatusCode;
@@ -101,15 +102,58 @@ public boolean supports(@NonNull Class<?> type) {
101102
try {
102103
return java.time.Duration.parse(value.value());
103104
} catch (DateTimeParseException x) {
104-
try {
105-
long duration =
106-
ConfigFactory.empty()
107-
.withValue("d", ConfigValueFactory.fromAnyRef(value.value()))
108-
.getDuration("d", TimeUnit.MILLISECONDS);
109-
return java.time.Duration.ofMillis(duration);
110-
} catch (ConfigException ignored) {
111-
throw x;
105+
var nanos = MILLISECONDS.convert(parseDuration(value.value()), NANOSECONDS);
106+
return java.time.Duration.ofMillis(nanos);
107+
}
108+
}
109+
110+
/**
111+
* Parses a duration string. If no units are specified in the string, it is assumed to be in
112+
* milliseconds. The returned duration is in nanoseconds. The purpose of this function is to
113+
* implement the duration-related methods in the ConfigObject interface.
114+
*
115+
* @param value the string to parse
116+
* @return duration in nanoseconds
117+
*/
118+
private static long parseDuration(String value) {
119+
var unitString = getUnits(value);
120+
var numberString = value.substring(0, value.length() - unitString.length());
121+
122+
// this would be caught later anyway, but the error message
123+
// is more helpful if we check it here.
124+
if (numberString.isEmpty()) {
125+
throw new DateTimeParseException(
126+
"No number in duration value: '" + numberString + "'", value, 0);
127+
}
128+
129+
// note that this is deliberately case-sensitive
130+
var units =
131+
switch (unitString) {
132+
case "ms", "milli", "millis", "millisecond", "milliseconds", "" -> MILLISECONDS;
133+
case "us", "micro", "micros", "microsecond", "microseconds" -> TimeUnit.MICROSECONDS;
134+
case "ns", "nano", "nanos", "nanosecond", "nanoseconds" -> NANOSECONDS;
135+
case "s", "second", "seconds" -> TimeUnit.SECONDS;
136+
case "m", "minute", "minutes" -> TimeUnit.MINUTES;
137+
case "h", "hour", "hours" -> TimeUnit.HOURS;
138+
case "d", "day", "days" -> TimeUnit.DAYS;
139+
default ->
140+
throw new DateTimeParseException(
141+
"Could not parse time unit '" + unitString + "'",
142+
value,
143+
value.length() - unitString.length());
144+
};
145+
try {
146+
// if the string is purely digits, parse as an integer to avoid possible precision loss;
147+
// otherwise as a double.
148+
if (numberString.matches("[+-]?[0-9]+")) {
149+
return units.toNanos(Long.parseLong(numberString));
150+
} else {
151+
long nanosInUnit = units.toNanos(1);
152+
return (long) (Double.parseDouble(numberString) * nanosInUnit);
112153
}
154+
} catch (NumberFormatException e) {
155+
throw new DateTimeParseException(
156+
"Could not parse duration number '" + numberString + "'", numberString, 0);
113157
}
114158
}
115159
},
@@ -121,7 +165,63 @@ public boolean supports(@NonNull Class<?> type) {
121165

122166
@Override
123167
public @NonNull Object convert(@NonNull Value value, @NonNull Class<?> type) {
124-
return java.time.Period.from((Duration) Duration.convert(value, type));
168+
try {
169+
return java.time.Period.from((Duration) Duration.convert(value, type));
170+
} catch (DateTimeException x) {
171+
return parsePeriod(value.value());
172+
}
173+
}
174+
175+
/**
176+
* Parses a period string. If no units are specified in the string, it is assumed to be in days.
177+
* The returned period is in days. The purpose of this function is to implement the
178+
* period-related methods in the ConfigObject interface.
179+
*
180+
* @param value the string to parse path to include in exceptions
181+
* @return duration in days
182+
*/
183+
public static Period parsePeriod(String value) {
184+
var unitString = getUnits(value);
185+
var numberString = value.substring(0, value.length() - unitString.length());
186+
187+
// this would be caught later anyway, but the error message
188+
// is more helpful if we check it here.
189+
if (numberString.isEmpty())
190+
throw new DateTimeParseException(
191+
"No number in period value '" + numberString + "'", numberString, 0);
192+
193+
var units =
194+
switch (unitString) {
195+
case "d", "day", "days", "" -> ChronoUnit.DAYS;
196+
case "w", "week", "weeks" -> ChronoUnit.WEEKS;
197+
case "m", "mo", "month", "months" -> ChronoUnit.MONTHS;
198+
case "y", "year", "years" -> ChronoUnit.YEARS;
199+
default ->
200+
throw new DateTimeParseException(
201+
"Could not parse time unit '" + unitString + "' (try d, w, mo, y)",
202+
value,
203+
value.length() - unitString.length());
204+
};
205+
try {
206+
return periodOf(Integer.parseInt(numberString), units);
207+
} catch (NumberFormatException e) {
208+
throw new DateTimeParseException(
209+
"Could not parse duration number '" + numberString + "'", numberString, 0);
210+
}
211+
}
212+
213+
private static Period periodOf(int n, ChronoUnit unit) {
214+
if (unit.isTimeBased()) {
215+
throw new DateTimeException(unit + " cannot be converted to a java.time.Period");
216+
}
217+
218+
return switch (unit) {
219+
case DAYS -> java.time.Period.ofDays(n);
220+
case WEEKS -> java.time.Period.ofWeeks(n);
221+
case MONTHS -> java.time.Period.ofMonths(n);
222+
case YEARS -> java.time.Period.ofYears(n);
223+
default -> throw new DateTimeException(unit + " cannot be converted to a java.time.Period");
224+
};
125225
}
126226
},
127227
Instant {
@@ -239,4 +339,14 @@ public boolean supports(@NonNull Class<?> type) {
239339
return java.time.ZoneId.of(java.time.ZoneId.SHORT_IDS.getOrDefault(zoneId, zoneId));
240340
}
241341
};
342+
343+
private static String getUnits(String s) {
344+
int i = s.length() - 1;
345+
while (i >= 0) {
346+
char c = s.charAt(i);
347+
if (!Character.isLetter(c)) break;
348+
i -= 1;
349+
}
350+
return s.substring(i + 1);
351+
}
242352
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
10+
import java.time.Duration;
11+
import java.time.Period;
12+
import java.util.concurrent.TimeUnit;
13+
14+
import org.junit.jupiter.api.Test;
15+
import org.mockito.Mockito;
16+
17+
import io.jooby.Value;
18+
import io.jooby.internal.converter.BuiltinConverter;
19+
20+
public class DurationConverterTest {
21+
22+
@Test
23+
public void convertDuration() {
24+
var nanos = System.nanoTime();
25+
assertEquals(Duration.ofSeconds(1), duration("1s"));
26+
assertEquals(Duration.ofNanos(nanos).toMillis(), duration(nanos + "ns").toMillis());
27+
assertEquals(TimeUnit.MICROSECONDS.toMillis(1000), duration((1000) + "us").toMillis());
28+
assertEquals(Duration.ofMillis(500), duration("500ms"));
29+
assertEquals(Duration.ofMinutes(5), duration("5m"));
30+
assertEquals(Duration.ofDays(8), duration("8d"));
31+
assertEquals(Duration.ofHours(15), duration("15h"));
32+
}
33+
34+
@Test
35+
public void convertPeriod() {
36+
assertEquals(Period.ofDays(1), period("1d"));
37+
assertEquals(Period.ofDays(1), period("1"));
38+
assertEquals(Period.ofDays(1), period("1day"));
39+
assertEquals(Period.ofDays(1), period("1days"));
40+
41+
assertEquals(Period.ofMonths(2), period("2m"));
42+
assertEquals(Period.ofMonths(3), period("3mo"));
43+
assertEquals(Period.ofMonths(4), period("4month"));
44+
assertEquals(Period.ofMonths(5), period("5months"));
45+
46+
assertEquals(Period.ofWeeks(2), period("2w"));
47+
assertEquals(Period.ofWeeks(3), period("3week"));
48+
assertEquals(Period.ofWeeks(4), period("4weeks"));
49+
50+
assertEquals(Period.ofYears(2), period("2y"));
51+
assertEquals(Period.ofYears(3), period("3year"));
52+
assertEquals(Period.ofYears(4), period("4years"));
53+
}
54+
55+
private Duration duration(String value) {
56+
return (Duration) BuiltinConverter.Duration.convert(value(value), Duration.class);
57+
}
58+
59+
private Period period(String value) {
60+
return (Period) BuiltinConverter.Period.convert(value(value), Period.class);
61+
}
62+
63+
private Value value(String value) {
64+
var mock = Mockito.mock(Value.class);
65+
Mockito.when(mock.value()).thenReturn(value);
66+
return mock;
67+
}
68+
}

0 commit comments

Comments
 (0)