Skip to content

Commit 30a3f11

Browse files
authored
Allow items to have both time ranges and point in time datetimes (#405)
* switch item datetimes from ADT to cats Ior * remove stale old print * fix encoder order is important to avoid accidentally overwriting the datetime field * handle case where two generated datetimes are equal * Update changelog * suppress warning * update STAC versions in tests
1 parent 0753d7b commit 30a3f11

File tree

12 files changed

+112
-135
lines changed

12 files changed

+112
-135
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
## [Unreleased]
7+
### Fixed
8+
- Allowed items to have both point in time and time range datetimes [#405](https://github.com/azavea/stac4s/pull/405)
79

810
## [0.6.2] - 2021-07-29
911
### Fixed

modules/core-test/js/src/test/scala/com/azavea/stac4s/JsSerDeSpec.scala

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,14 @@ class JsSerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with
1616
checkAll("Codec.ItemCollection", CodecTests[ItemCollection].unserializableCodec)
1717
checkAll("Codec.StacItem", CodecTests[StacItem].unserializableCodec)
1818
checkAll("Codec.Geometry", CodecTests[Geometry].unserializableCodec)
19-
checkAll("Codec.ItemDatetime", CodecTests[ItemDatetime].unserializableCodec)
20-
checkAll("Codec.ItemProperties", CodecTests[ItemProperties].unserializableCodec)
2119

2220
/** Ensure that the datetime field is present but null for time ranges
2321
*
2422
* Specification:
2523
* https://github.com/radiantearth/stac-spec/blob/v1.0.0-rc.4/item-spec/common-metadata.md#date-and-time-range
2624
*/
2725
test("Encoded time ranges print null datetime") {
28-
val tr = ItemDatetime.TimeRange(
26+
val tr = TimeRange(
2927
Instant.parse("2021-01-01T00:00:00Z"),
3028
Instant.parse("2022-01-01T00:00:00Z")
3129
)

modules/core-test/jvm/src/test/scala/com/azavea/stac4s/JvmSerDeSpec.scala

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,14 @@ class JvmSerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers wit
2929
checkAll("Codec.StacLayer", CodecTests[StacLayer].unserializableCodec)
3030
checkAll("Codec.PeriodDuration", CodecTests[PeriodDuration].unserializableCodec)
3131
checkAll("Codec.PeriodicExtent", CodecTests[PeriodicExtent].unserializableCodec)
32-
checkAll("Codec.ItemDatetime", CodecTests[ItemDatetime].unserializableCodec)
33-
checkAll("Codec.ItemProperties", CodecTests[ItemProperties].unserializableCodec)
3432

3533
/** Ensure that the datetime field is present but null for time ranges
3634
*
3735
* Specification:
3836
* https://github.com/radiantearth/stac-spec/blob/v1.0.0-rc.4/item-spec/common-metadata.md#date-and-time-range
3937
*/
4038
test("Encoded time ranges print null datetime") {
41-
val tr = ItemDatetime.TimeRange(
39+
val tr = TimeRange(
4240
Instant.parse("2021-01-01T00:00:00Z"),
4341
Instant.parse("2022-01-01T00:00:00Z")
4442
)

modules/core-test/shared/src/test/scala/com/azavea/stac4s/SerDeSpec.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.azavea.stac4s.extensions.label._
66
import com.azavea.stac4s.extensions.layer._
77
import com.azavea.stac4s.meta.ForeignImplicits._
88
import com.azavea.stac4s.testing.TestInstances._
9+
import com.azavea.stac4s.types._
910

1011
import io.circe.Decoder
1112
import io.circe.parser._
@@ -33,6 +34,8 @@ class SerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with M
3334
checkAll("Codec.StacProviderRole", CodecTests[StacProviderRole].unserializableCodec)
3435
checkAll("Codec.ThreeDimBbox", CodecTests[ThreeDimBbox].unserializableCodec)
3536
checkAll("Codec.TwoDimBbox", CodecTests[TwoDimBbox].unserializableCodec)
37+
checkAll("Codec.ItemDatetime", CodecTests[ItemDatetime].unserializableCodec)
38+
checkAll("Codec.ItemProperties", CodecTests[ItemProperties].unserializableCodec)
3639

3740
// extensions
3841

@@ -72,7 +75,6 @@ class SerDeSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers with M
7275
private def accumulatingDecodeTest[T: Decoder]: Assertion =
7376
decodeAccumulating[T]("{}").fold(
7477
errs => {
75-
println(s"Errs: $errs")
7678
errs.size should be > 1
7779
},
7880
_ => fail("Decoding succeeded but should not have")

modules/core/js/src/main/scala/com/azavea/stac4s/StacItem.scala

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import com.azavea.stac4s.geometry.Geometry
44

55
import cats.Eq
66
import io.circe._
7-
import monocle.macros.{GenLens, GenPrism}
8-
import monocle.{Lens, Optional}
7+
import monocle.Lens
8+
import monocle.macros.GenLens
99

1010
final case class StacItem(
1111
id: String,
@@ -28,20 +28,8 @@ final case class StacItem(
2828

2929
object StacItem {
3030

31-
private val datetimeField: Lens[StacItem, ItemDatetime] = GenLens[StacItem](_.properties.datetime)
32-
33-
private val toDatetime = GenPrism[ItemDatetime, ItemDatetime.PointInTime]
34-
35-
private val toTimeRange = GenPrism[ItemDatetime, ItemDatetime.TimeRange]
36-
3731
val propertiesExtension: Lens[StacItem, JsonObject] = GenLens[StacItem](_.properties.extensionFields)
3832

39-
val datetimePrism: Optional[StacItem, ItemDatetime.PointInTime] =
40-
datetimeField.composePrism(toDatetime)
41-
42-
val timeRangePrism: Optional[StacItem, ItemDatetime.TimeRange] =
43-
datetimeField.composePrism(toTimeRange)
44-
4533
implicit val eqStacItem: Eq[StacItem] = Eq.fromUniversalEquals
4634

4735
implicit val encStacItem: Encoder[StacItem] = Encoder

modules/core/jvm/src/main/scala/com/azavea/stac4s/StacItem.scala

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import com.azavea.stac4s.meta._
55
import cats.Eq
66
import geotrellis.vector.Geometry
77
import io.circe._
8-
import monocle.macros.{GenLens, GenPrism}
9-
import monocle.{Lens, Optional}
8+
import monocle.Lens
9+
import monocle.macros.GenLens
1010

1111
final case class StacItem(
1212
id: String,
@@ -29,20 +29,8 @@ final case class StacItem(
2929

3030
object StacItem {
3131

32-
private val datetimeField: Lens[StacItem, ItemDatetime] = GenLens[StacItem](_.properties.datetime)
33-
34-
private val toDatetime = GenPrism[ItemDatetime, ItemDatetime.PointInTime]
35-
36-
private val toTimeRange = GenPrism[ItemDatetime, ItemDatetime.TimeRange]
37-
3832
val propertiesExtension: Lens[StacItem, JsonObject] = GenLens[StacItem](_.properties.extensionFields)
3933

40-
val datetimePrism: Optional[StacItem, ItemDatetime.PointInTime] =
41-
datetimeField.composePrism(toDatetime)
42-
43-
val timeRangePrism: Optional[StacItem, ItemDatetime.TimeRange] =
44-
datetimeField.composePrism(toTimeRange)
45-
4634
implicit val eqStacItem: Eq[StacItem] = Eq.fromUniversalEquals
4735

4836
implicit val encStacItem: Encoder[StacItem] = Encoder
Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,30 @@
11
package com.azavea.stac4s
22

3-
import cats.kernel.Eq
43
import cats.syntax.apply._
5-
import cats.syntax.functor._
6-
import io.circe.syntax._
74
import io.circe.{Decoder, Encoder, HCursor}
85

96
import java.time.Instant
107

11-
sealed abstract class ItemDatetime
8+
case class PointInTime(when: Instant)
129

13-
object ItemDatetime {
10+
case class TimeRange(start: Instant, end: Instant)
1411

15-
case class PointInTime(when: Instant) extends ItemDatetime
16-
17-
case class TimeRange(start: Instant, end: Instant) extends ItemDatetime
18-
19-
implicit val eqItemDatetime: Eq[ItemDatetime] = Eq.fromUniversalEquals
20-
21-
implicit val decPointInTime: Decoder[PointInTime] = { cursor: HCursor =>
22-
cursor.get[Instant]("datetime") map { PointInTime }
23-
}
12+
object TimeRange {
2413

2514
implicit val decTimeRange: Decoder[TimeRange] = { cursor: HCursor =>
26-
(cursor.get[Instant]("start_datetime"), cursor.get[Instant]("end_datetime")) mapN { TimeRange }
15+
(cursor.get[Instant]("start_datetime"), cursor.get[Instant]("end_datetime")) mapN { TimeRange.apply }
2716
}
2817

29-
implicit val decItemDatetime: Decoder[ItemDatetime] = decPointInTime.widen or decTimeRange.widen
30-
31-
implicit val encPointInTime: Encoder[PointInTime] = Encoder.forProduct1("datetime")(_.when)
32-
3318
implicit val encTimeRange: Encoder[TimeRange] =
3419
Encoder.forProduct3("datetime", "start_datetime", "end_datetime")(range =>
3520
(Option.empty[Instant], range.start, range.end)
3621
)
22+
}
3723

38-
implicit val encItemDateTime: Encoder[ItemDatetime] = {
39-
case pit @ PointInTime(_) => pit.asJson
40-
case tr @ TimeRange(_, _) => tr.asJson
24+
object PointInTime {
25+
26+
implicit val decPointInTime: Decoder[PointInTime] = { cursor: HCursor =>
27+
cursor.get[Instant]("datetime") map { PointInTime.apply }
4128
}
29+
implicit val encPointInTime: Encoder[PointInTime] = Encoder.forProduct1("datetime")(_.when)
4230
}

modules/core/shared/src/main/scala/com/azavea/stac4s/ItemProperties.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.azavea.stac4s
22

3+
import com.azavea.stac4s.types._
4+
35
import cats.data.NonEmptyList
46
import cats.kernel.Eq
57
import cats.syntax.apply._
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,46 @@
11
package com.azavea.stac4s
22

3+
import cats.data.Ior
4+
import cats.kernel.Eq
35
import eu.timepit.refined.W
46
import eu.timepit.refined.api.Refined
57
import eu.timepit.refined.generic._
8+
import io.circe.syntax._
9+
import io.circe.{Decoder, DecodingFailure, Encoder, HCursor}
610

711
package object types {
812

913
type CatalogType = String Refined Equal[W.`"Catalog"`.T]
1014
type CollectionType = String Refined Equal[W.`"Collection"`.T]
15+
16+
type ItemDatetime = Ior[PointInTime, TimeRange]
17+
18+
implicit val encItemDateTime: Encoder[ItemDatetime] = {
19+
case Ior.Left(pit @ PointInTime(_)) => pit.asJson
20+
case Ior.Right(tr @ TimeRange(_, _)) => tr.asJson
21+
case Ior.Both(pit @ PointInTime(_), tr @ TimeRange(_, _)) =>
22+
// order is important here! the time range encoder also writes a `null` value to the
23+
// datetime field, which overwrites what the point-in-time encoder wants to write.
24+
val out = tr.asJson.deepMerge(pit.asJson)
25+
out
26+
}
27+
28+
implicit val decItemDateTime: Decoder[ItemDatetime] = { c: HCursor =>
29+
(c.as[PointInTime], c.as[TimeRange]) match {
30+
case (Right(pit), Right(tr)) => Right(Ior.Both(pit, tr))
31+
case (_, Right(tr)) => Right(Ior.Right(tr))
32+
case (Right(pit), _) => Right(Ior.Left(pit))
33+
case (Left(err1), Left(err2)) =>
34+
(err1, err2) match {
35+
case (DecodingFailure(decFailure1, h1), DecodingFailure(decFailure2, h2)) =>
36+
Left(DecodingFailure(s"${decFailure1}. ${decFailure2}", h1 ++ h2))
37+
// since they're decoding the same cursor, if one of the errors is a ParsingFailure instead
38+
// of a decoding failure, they _both_ should be, so we just need the first one
39+
case _ =>
40+
Left(err1)
41+
}
42+
}
43+
}
44+
45+
implicit val eqItemDatetime: Eq[ItemDatetime] = Eq.fromUniversalEquals
1146
}

modules/testing/js/src/main/scala/JsInstances.scala

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import com.azavea.stac4s.{
88
Bbox,
99
Interval,
1010
ItemCollection,
11-
ItemDatetime,
12-
ItemProperties,
1311
Proprietary,
1412
SpatialExtent,
1513
StacAsset,
@@ -62,29 +60,29 @@ trait JsInstances extends GenericInstances {
6260
private[testing] def stacItemGen: Gen[StacItem] =
6361
(
6462
nonEmptyStringGen,
65-
Gen.const("1.0.0-rc2"),
63+
Gen.const("1.0.0"),
6664
Gen.const(List.empty[String]),
6765
Gen.const("Feature"),
6866
geometryGen,
6967
TestInstances.twoDimBboxGen,
7068
nonEmptyListGen(TestInstances.stacLinkGen) map { _.toList },
7169
TestInstances.assetMapGen,
7270
Gen.option(nonEmptyStringGen),
73-
itemPropertiesGen
71+
TestInstances.itemPropertiesGen
7472
).mapN(StacItem.apply)
7573

7674
private[testing] def stacItemShortGen: Gen[StacItem] =
7775
(
7876
nonEmptyStringGen,
79-
Gen.const("1.0.0-rc2"),
77+
Gen.const("1.0.0"),
8078
Gen.const(List.empty[String]),
8179
Gen.const("Feature"),
8280
geometryGen,
8381
TestInstances.twoDimBboxGen,
8482
Gen.const(Nil),
8583
Gen.const(Map.empty[String, StacAsset]),
8684
Gen.option(nonEmptyStringGen),
87-
itemPropertiesGen
85+
TestInstances.itemPropertiesGen
8886
).mapN(StacItem.apply)
8987

9088
private[testing] def itemCollectionGen: Gen[ItemCollection] =
@@ -110,7 +108,7 @@ trait JsInstances extends GenericInstances {
110108
private[testing] def stacCollectionShortGen: Gen[StacCollection] =
111109
(
112110
Arbitrary.arbitrary[CollectionType],
113-
Gen.const("1.0.0-rc2"),
111+
Gen.const("1.0.0"),
114112
Gen.const(Nil),
115113
nonEmptyStringGen,
116114
nonEmptyStringGen.map(_.some),
@@ -137,30 +135,6 @@ trait JsInstances extends GenericInstances {
137135
StacLayer.apply
138136
)
139137

140-
private def itemDateTimeGen: Gen[ItemDatetime] = Gen.oneOf[ItemDatetime](
141-
instantGen map { ItemDatetime.PointInTime },
142-
(instantGen, instantGen) mapN {
143-
case (i1, i2) if i2.isAfter(i1) => ItemDatetime.TimeRange(i1, i2)
144-
case (i1, i2) => ItemDatetime.TimeRange(i2, i1)
145-
}
146-
)
147-
148-
private def itemPropertiesGen: Gen[ItemProperties] = (
149-
itemDateTimeGen,
150-
Gen.option(nonEmptyAlphaRefinedStringGen),
151-
Gen.option(nonEmptyAlphaRefinedStringGen),
152-
Gen.option(instantGen),
153-
Gen.option(instantGen),
154-
Gen.option(TestInstances.stacLicenseGen),
155-
Gen.option(nonEmptyListGen(TestInstances.stacProviderGen)),
156-
Gen.option(nonEmptyAlphaRefinedStringGen),
157-
Gen.option(nonEmptyListGen(nonEmptyAlphaRefinedStringGen)),
158-
Gen.option(nonEmptyAlphaRefinedStringGen),
159-
Gen.option(nonEmptyAlphaRefinedStringGen),
160-
Gen.option(TestInstances.finiteDoubleGen),
161-
TestInstances.itemExtensionFieldsGen
162-
) mapN { ItemProperties.apply }
163-
164138
implicit val arbItem: Arbitrary[StacItem] = Arbitrary { stacItemGen }
165139

166140
val arbItemShort: Arbitrary[StacItem] = Arbitrary { stacItemShortGen }
@@ -181,13 +155,6 @@ trait JsInstances extends GenericInstances {
181155
stacLayerGen
182156
}
183157

184-
implicit val arbItemDatetime: Arbitrary[ItemDatetime] = Arbitrary {
185-
itemDateTimeGen
186-
}
187-
188-
implicit val arbItemProperties: Arbitrary[ItemProperties] = Arbitrary {
189-
itemPropertiesGen
190-
}
191158
}
192159

193160
object JsInstances extends JsInstances {}

0 commit comments

Comments
 (0)