tl;dr: Careful with validation via LocalDate
parsing
The original bug
We had a bug reported on our Android app this week about invalid dates coming into the backend during a particular UI flow. The problematic section was a page where users could enter the date of an event, then we call the backend API with the date they entered. The issue they were seeing is that the date we were sending wasn’t valid for some odd reason.
It turned out that at least one user entered the date June 31st, 2022, which isn’t a real day. This was confusing, as we have validation in place to confirm what the user entered:
LocalDate.parse(
dateString,
DateTimeFormatter.ofPattern("MM/dd/yyyy", Locale.US)
)
We then either use the value returned from the parse(...)
call or catch the DateTimeParseException
and display an error. This is all logical, so why was June 31st allowed?
LocalDate
parsing with DateTimeFormatter
It turns out the default approach with some invalid dates is to effectively round the result to the end of a month. This means code like this:
LocalDate.parse(
"06/31/2022", DateTimeFormatter.ofPattern("MM/dd/yyyy")
)
actually has the date June 30th rather than throw a DateTimeParseException
.
Now, if we used "02/33/2022"
instead, that throws the exception as you’d expect. It turns out that if the day of the month is one that’s valid in some cases (meaning the date is 1-31) but is outside the limit of a given month, it uses that last day instead.
I then decided to give a different parser a shot.
LocalDate
parsing with SimpleDateFormat
SimpleDateFormat
has a similar enough syntax, so I figured this could be an easy fix.
SimpleDateFormat("MM/dd/yyyy").parse(dateString)
Nope.
SimpleDateFormat
actually goes the other way, meaning if we’re past the valid days for a month, it keeps adding on days. In other words, June 31st becomes July 1st and something like Feb. 31st becomes March 3rd.
Remember the joke about how March 2020 seemed to just go on forever during the pandemic? That works with SimpleDateFormat
. It can handle a date like March 406th, 2020 and give you April 10th, 2021. This is in no way useful, but it’s at least amusing.
Now, there is a way to make SimpleDateFormat
work a bit better here. We can always set the isLenient
flag to false
, meaning only valid dates get parsed.
val dateFormat = SimpleDateFormat("MM/dd/yyyy").apply { isLenient = false }
dateFormat.parse(dateString)
This is much better as it actually does what we want. We need to catch an exception if the parsing fails, which I don’t love, but it works fine. Also, we can always throw this into a function:
fun parseLocalDateOrNull(dateString: String, dateFormat: String) =
try {
SimpleDateFormat(dateFormat).apply { isLenient = false }.parse(dateString)
} catch (e: ParseException) {
null
}
This works, but I really felt like DateTimeFormatter
should work as well. Given that SimpleDateFormat
needed that isLenient
flag, maybe something similar lives on DateTimeFormatter
.
Plus, I saw this blog where they use DateTimeFormatter
exactly as I expected, so I knew it had to work in some fashion.
Back to DateTimeFormatter
It turns out, the key with DateTimeFormatter
is the ResolverStyle
class. We can add an extra call, .withResolverStyle(ResolverStyle.STRICT)
, to make the call throw an exception if the date string is invalid in any way.
In that post I mentioned before, they used the BASIC_ISO_DATE
formatter. It turns out that BASIC_ISO_DATE
internally uses the parseStrict()
function when building the formatter, which means we’re on the right track!
Time for the new DateTimeFormatter
:
LocalDate.parse(
dateString,
DateTimeFormatter
.ofPattern("MM/dd/yyyy", Locale.US)
.withResolverStyle(ResolverStyle.STRICT)
)
Nope again.
The above parser threw an exception for "06/31/2022"
, but it also threw one for "06/30/2022"
and every other date I tried. What’s going on here? Now it’s too strict!
As it turns out, the issue is with the "yyyy"
part of the format. As mentioned in this StackOverflow post:
Java 8 uses uuuu for year, not yyyy. In Java 8, yyyy means “year of era” (BC or AD) and the error message complains that MonthOfYear, DayOfMonth and YearOfEra is not enough information to construct the date because era is not known.
This means that the code I wanted in the first place is this block:
LocalDate.parse(
dateString,
DateTimeFormatter
.ofPattern("MM/dd/uuuu", Locale.US)
.withResolverStyle(ResolverStyle.STRICT)
)
With both DateTimeFormatter
and SimpleDateFormat
, the default functionality isn’t doing what I had expected and caused some trouble. Moral of the story: make sure you know how you get your dates and once again, dates/times are the bane of a developer’s existence.