-
-
Notifications
You must be signed in to change notification settings - Fork 134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Codecs for Java TemporalAmount #43
Codecs for Java TemporalAmount #43
Conversation
private def javaDurationNumberReads(unit: TemporalUnit, esuffix: String) = | ||
Reads[JDuration] { | ||
case JsNumber(n) if !n.ulp.isWhole => | ||
JsError(s"error.invalid.$esuffix") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
error.expected.integer
? Or we could support fractional values and just round to the nearest nanosecond.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer to have an error key related to the duration case.
I don't think rounding should happen there. The temporal unit is specified and a corresponding integer should be found, or the validation indicates there is a misusage.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we make it an error key that suggests an issue with the number being an integer? error.invalid.duration-int
?
Also, numbers like 10.0
that have trailing zeros should still be considered integers by the validation. You might want to do a stripTrailingZeros
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, you probably don't need the esuffix
anymore since this is only used for Duration
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
scala> BigDecimal(10.0D).isWhole
res0: Boolean = true
scala> BigDecimal("10.0").isWhole
res1: Boolean = true
Reads[Period] { js => | ||
underlying.reads(js).flatMap { duration => | ||
try { | ||
JsSuccess(Period.ofDays(duration.toDays.toInt)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you are reading a Period
, you'd want to get a period using the unit
you requested. And with Period
s it's important what the units are because a period could correspond to a different duration based on when it starts on the calendar (due to leap seconds/days, daylight saving time, etc.).
Actually, it probably makes sense to only support temporal units supported by Period
.
case JsString(repr) => try { | ||
JsSuccess(JDuration.parse(repr)) | ||
} catch { | ||
case _: Throwable => JsError("error.invalid.duration") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's only catch DateTimeParseException
here, and for the other parse
calls.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see reason not to catch any exception there, even if for now only this one is supposed to happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duration.parse
is part of the Java standard library, so it's probably safe to assume that if it only says @throws DateTimeParseException
, that's the only thing you're expected to handle. Any other kind of exception likely has nothing to do with whether the duration is valid. I would prefer those exceptions be passed up to the caller so they can inspect the exception and figure out what to do.
try { | ||
JsSuccess(Period.ofDays(duration.toDays.toInt)) | ||
} catch { | ||
case e: Throwable => JsError("error.invalid.period") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think any exception here would indicate an invalid period, so the whole try-catch can be removed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As for { Future, Try }.{ map, flatMap }
, JsResult
should be safe. An exception while parsing period should be represented with the JsResult
not letting the exception bubbling up.
The point is there is no general mechanism about that in the JsResult
combinators.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An exception while parsing period should be represented with the JsResult not letting the exception bubbling up.
I disagree. JsResult
is not a Try
. JsError
really only has the capacity to represent JSON parsing and validation errors, and I'm pretty sure that's what users expect of it. By presenting any exception as if it were invalid input, you're potentially masking other more dangerous errors.
} | ||
|
||
/** Deserializer of Java Period from a number of days. */ | ||
val javaPeriodDaysReads: Reads[Period] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about:
object JavaPeriodReads {
val Days = javaPeriodNumberReads(ChronoUnit.DAYS)
val Months = javaPeriodNumberReads(ChronoUnit.MONTHS)
val Years = javaPeriodNumberReads(ChronoUnit.YEARS)
}
|
||
/** Serializer of Java Period as a number of days. */ | ||
val javaPeriodDaysWrites: Writes[Period] = | ||
Writes[Period] { d => JsNumber(d.getDays) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getDays
returns only the days
field in the Period
. I'd think you'd want to convert the months and years to days and include those at well. You could use Duration.from(period).toDays
.
It would also be useful to have constants for years and months like I suggested for the Reads
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed there is no stable way to write a Period
as number (Duration.from(aPeriod)
is not supported as Period
is an estimated duration).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we can just avoid writing periods as numbers.
private def javaDurationNumberReads(unit: TemporalUnit, esuffix: String) = | ||
Reads[JDuration] { | ||
case JsNumber(n) if !n.ulp.isWhole => | ||
JsError(s"error.invalid.$esuffix") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we make it an error key that suggests an issue with the number being an integer? error.invalid.duration-int
?
Also, numbers like 10.0
that have trailing zeros should still be considered integers by the validation. You might want to do a stripTrailingZeros
.
case JsNumber(n) if !n.ulp.isWhole => | ||
JsError(s"error.invalid.$esuffix") | ||
|
||
case JsNumber(n) => JsSuccess(JDuration.of(n.toLong, unit)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should probably return an error if the number isn't representable as long. BigDecimal#toLong
will start providing nonsense values if the number is outside the range of a long, for example
scala> val longValue = BigDecimal("1E20").toLong
longValue: Long = 7766279631452241920
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
scala> BigDecimal("1E20").isValidLong
res3: Boolean = false
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So it seems like we should be using .isValidLong
instead of .ulp.isWhole
.
private def javaDurationNumberReads(unit: TemporalUnit, esuffix: String) = | ||
Reads[JDuration] { | ||
case JsNumber(n) if !n.ulp.isWhole => | ||
JsError(s"error.invalid.$esuffix") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, you probably don't need the esuffix
anymore since this is only used for Duration
.
case JsString(repr) => try { | ||
JsSuccess(JDuration.parse(repr)) | ||
} catch { | ||
case _: Throwable => JsError("error.invalid.duration") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duration.parse
is part of the Java standard library, so it's probably safe to assume that if it only says @throws DateTimeParseException
, that's the only thing you're expected to handle. Any other kind of exception likely has nothing to do with whether the duration is valid. I would prefer those exceptions be passed up to the caller so they can inspect the exception and figure out what to do.
} | ||
|
||
/** Deserializer of Java Period from a number (integer) of days. */ | ||
val javaPeriodDaysReads: Reads[Period] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're not going to have a Writes
for this maybe we should skip the Reads
too? This is basically equivalent to implicitly[Reads[Int]].map(Period.ofDays(_))
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right
private def javaPeriodNumberReads(f: Int => Period): Reads[Period] = | ||
Reads[Period] { | ||
case JsNumber(n) if !n.isValidInt => | ||
JsError(s"error.invalid.period") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another note about message keys: I would expect the error to be different if I'm reading a number versus if I'm reading as a period string. I'd assume error.invalid.period
would correspond to a message like "Invalid period", which is not enough information for me to understand why this is failing.
94f11dd
to
e46f683
Compare
Updated |
e46f683
to
3177f1d
Compare
JsError("error.invalid.longDuration") | ||
|
||
case JsNumber(n) => JsSuccess(JDuration.of(n.toLong, unit)) | ||
case _ => JsError("error.expected.duration") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
error.expected.longDuration
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated
case JsString(repr) => try { | ||
JsSuccess(JDuration.parse(repr)) | ||
} catch { | ||
case _: Exception => JsError("error.invalid.duration") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be case _: DateTimeParseException
since that's the only exception that indicates an invalid duration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Either we catch nothing there, having to ensure there is a generic mechanism making JsResult
safe (for me no exception must be raised from), or for now Reads
such as this one should catch everything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would consider it unsafe to catch an unexpected exception and map it to what looks like a validation error. This hides the true cause of the error from the caller.
If code like this throws an exception we don't already know about, it's likely because of either a bug or another serious issue on the server. I don't believe these things are our job to handle. JsResult
is not a generic error handling mechanism; it's specifically for JSON validation. If you want to handle other exceptions, you should use a Try
. With JsResult.toTry
, you can even do:
val result: Try[Foo] = Try(Json.fromJson[Foo](json)).flatMap(JsResult.toTry)
If we are trying to make the API safer, I would start with methods like Json.parse
, and make sure we at least document the exceptions thrown if the JSON is invalid. Then we can start thinking about how to provide better ways for handling exceptions in general. But I think that is best done at a high level rather than being the responsibility of each individual Reads
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok let this aside from this PR.
Will try to draft something about separately.
case JsString(repr) => try { | ||
JsSuccess(Period.parse(repr)) | ||
} catch { | ||
case _: Exception => JsError("error.invalid.stringPeriod") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DateTimeParseException
Support Java Time
Duration
(see #40 ) andPeriod
, either as number (by default of millis ofDuration
or ofdays
forPeriod
), or as ISO string representation.