We are attempting to write a single DateTimeFormatter that helps us validate an ISO 8601 that allows our end user's to enter just a year, a year and a month, or a year, month, and day. We also want to validate the entered date actually exists.
In the following code there are two examples of dates and date validation acting funky. The first is a validation test for just year with an optional month. Which doesn't properly validate a month of '00'.
The second example shows a test (NOT) failing on incorrect optional months value.
Any friendly direction would be greatly appreciated.
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.temporal.TemporalQuery;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class DateTimeFormatterForStackOverflowTest {
@Test
public void test_year_or_year_and_month_not_valid() {
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("uuuu[-MM]")
.toFormatter()
.withResolverStyle(ResolverStyle.STRICT);
this.expectException("1984-0", formatter, YearMonth::from, Year::from);
// Doesn't throw exception.
this.expectException("1984-00", formatter, YearMonth::from, Year::from);
// Doesn't throw exception.
this.expectException("1984-13", formatter, YearMonth::from, Year::from);
// Doesn't throw exception.
this.expectException("1984-99", formatter, YearMonth::from, Year::from);
}
@Test
public void test_year_or_year_month_or_year_month_day_not_valid() {
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("[uuuu-MM-dd][uuuu-MM][uuuu]")
.toFormatter()
.withResolverStyle(ResolverStyle.STRICT);
this.expectException("1984-0", formatter, LocalDate::from, YearMonth::from, Year::from);
// Doesn't throw exception.
this.expectException("1984-00", formatter, LocalDate::from, YearMonth::from, Year::from);
// Doesn't throw exception.
this.expectException("1984-13", formatter, LocalDate::from, YearMonth::from, Year::from);
// Doesn't throw exception.
this.expectException("1984-99", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-00-01", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-13-01", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-01-0", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-01-00", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-01-32", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-12-00", formatter, LocalDate::from, YearMonth::from, Year::from);
this.expectException("1984-12-32", formatter, LocalDate::from, YearMonth::from, Year::from);
}
private void expectException(String value, DateTimeFormatter formatter, TemporalQuery<?>... queries) {
assertThrows(DateTimeParseException.class,
() -> formatter.parseBest(value, queries));
}
}
Test output:
org.opentest4j.AssertionFailedError: Expected java.time.format.DateTimeParseException to be thrown, but nothing was thrown.
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:152)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:73)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35)
at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3083)
at DateTimeFormatterForStackOverflowTest.expectException(DateTimeFormatterForStackOverflowTest.java:59)
at DateTimeFormatterForStackOverflowTest.test_year_or_year_month_or_year_month_day_not_valid(DateTimeFormatterForStackOverflowTest.java:43)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727)
at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:156)
at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:147)
at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:103)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:93)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:92)
at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:86)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$7(TestMethodTestDescriptor.java:217)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:213)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:138)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
at org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService$ExclusiveTask.compute(ForkJoinPoolHierarchicalTestExecutorService.java:185)
at java.base/java.util.concurrent.RecursiveAction.exec(RecursiveAction.java:189)
at java.base/java.util.concurrent.ForkJoinTask.doExec$$$capture(ForkJoinTask.java:290)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java)
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1020)
at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1656)
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1594)
at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183)
Here is what we eventually did to solve our particular iso date validation issues.
First we created 5 different validators (one for each of the different iso date we accept) and each of those validators implemented the following interface:
package app.validation.type;
public interface IsoDateFormatValidator {
boolean isValid(String date);
}
We then created a validator that would go through a list of iso date based validators to see if the entered date is valid.
package app.validation.type;
import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.List;
public class ValidIsoDateFormatValidator implements ConstraintValidator<ValidIsoDateFormat, String> {
private final List<IsoDateFormatValidator> isoDateFormatValidatorList;
public ValidIsoDateFormatValidator(List<IsoDateFormatValidator> isoDateFormatValidatorList) {
this.isoDateFormatValidatorList = isoDateFormatValidatorList;
}
@Override
public void initialize(ValidIsoDateFormat annotation) {
}
@Override
public boolean isValid(String inputDate, ConstraintValidatorContext constraintValidatorContext) {
boolean valid = true;
if (StringUtils.hasText(inputDate)) {
valid = isoDateFormatValidatorList.stream()
.map(v -> v.isValid(inputDate))
.anyMatch((Boolean.TRUE::equals));
}
return valid;
}
}
Here are the individual iso date validators we used.
Date/Time YYYY-MM-DD HH:MM:SS
package app.validation.type;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
public class IsoDateTimeFormatValidator implements IsoDateFormatValidator {
private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd HH:mm:ss")
.toFormatter()
.withResolverStyle(ResolverStyle.LENIENT);
@Override
public boolean isValid(String date) {
boolean valid = false;
try {
LocalDateTime ldt = LocalDateTime.parse(date, formatter);
valid = true;
} catch (DateTimeParseException ignored) {
}
return valid;
}
}
Date/Time w/Milliseconds YYYY-MM-DD HH:MM:SS.SSS
package app.validation.type;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.temporal.TemporalAccessor;
public class IsoDateTimeMillisFormatValidator implements IsoDateFormatValidator {
private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd HH:mm:ss.SSS")
.toFormatter()
.withResolverStyle(ResolverStyle.LENIENT);
@Override
public boolean isValid(String date) {
boolean valid = false;
try {
TemporalAccessor ta = formatter.parse(date);
valid = true;
} catch (DateTimeParseException ignored) {
}
return valid;
}
}
Year YYYY
package app.validation.type;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
public class IsoYearFormatValidator implements IsoDateFormatValidator {
private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("uuuu")
.toFormatter();
@Override
public boolean isValid(String date) {
boolean valid = false;
try {
TemporalAccessor ta = formatter.parse(date);
valid = true;
} catch (DateTimeParseException ignored) {
}
return valid;
}
}
Year/Month YYYY/MM
package app.validation.type;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
public class IsoYearMonthFormatValidator implements IsoDateFormatValidator {
private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM")
.toFormatter();
@Override
public boolean isValid(String date) {
boolean valid = false;
try {
TemporalAccessor ta = formatter.parse(date);
valid = true;
} catch (DateTimeParseException ignored) {
}
return valid;
}
}
Year/Month/Day YYYY/MM/DD
package app.validation.type;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
public class IsoYearMonthDayFormatValidator implements IsoDateFormatValidator {
private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd")
.toFormatter()
.withResolverStyle ( ResolverStyle.LENIENT );
@Override
public boolean isValid(String date) {
boolean valid = false;
try {
LocalDate ld = LocalDate.parse (date, formatter);
valid = true;
} catch (DateTimeParseException ignored) {
}
return valid;
}
}
Of course if you have other iso date/time required validations that aren't listed here, that could be added since the ValidIsoDateFormatValidator
takes a list of IsoDateFormatValidator
s.