Skip to content

Commit 38d1dbb

Browse files
committed
Add a client module
1 parent 73b5734 commit 38d1dbb

File tree

5 files changed

+228
-1
lines changed

5 files changed

+228
-1
lines changed

build.sbt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ lazy val root = project
141141
.settings(commonSettings)
142142
.settings(publishSettings)
143143
.settings(noPublishSettings)
144-
.aggregate(core, testing, coreTest)
144+
.aggregate(core, testing, coreTest, client)
145145

146146
lazy val core = (project in file("modules/core"))
147147
.settings(commonSettings)
@@ -167,3 +167,19 @@ lazy val coreTest = (project in file("modules/core-test"))
167167
.settings(libraryDependencies ++= testRunnerDependencies map { _ % Test })
168168

169169
lazy val coreTestRef = LocalProject("modules/core-test")
170+
171+
lazy val client = (project in file("modules/client"))
172+
.dependsOn(core)
173+
.settings(commonSettings)
174+
.settings(publishSettings)
175+
.settings(libraryDependencies ++= coreDependencies)
176+
.settings(
177+
libraryDependencies ++= Seq(
178+
"org.http4s" %% "http4s-blaze-client" % "0.21.7",
179+
"org.http4s" %% "http4s-circe" % "0.21.7",
180+
"org.typelevel" %% "cats-effect" % "2.1.3",
181+
"io.chrisdavenport" %% "log4cats-core" % "1.1.1"
182+
)
183+
)
184+
185+
lazy val clientRef = LocalProject("modules/client")
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.azavea.stac4s.api.client
2+
3+
import com.azavea.stac4s.{StacCollection, StacItem}
4+
import org.http4s.Method.{GET, POST}
5+
import org.http4s.{Request, Uri}
6+
import org.http4s.client.Client
7+
import io.circe.syntax._
8+
import org.http4s.circe._
9+
import cats.syntax.functor._
10+
import cats.syntax.either._
11+
import cats.syntax.apply._
12+
import cats.effect.{ConcurrentEffect, Resource, Sync}
13+
import eu.timepit.refined.types.string.NonEmptyString
14+
import org.http4s.client.blaze.BlazeClientBuilder
15+
import io.chrisdavenport.log4cats.Logger
16+
17+
import scala.concurrent.ExecutionContext
18+
19+
case class Http4sStacClient[F[_]: Sync: Logger](
20+
client: Client[F],
21+
baseUri: Uri
22+
) extends StacClient[F] {
23+
private lazy val logger = Logger[F]
24+
private def postRequest = Request[F]().withMethod(POST)
25+
private def getRequest = Request[F]().withMethod(GET)
26+
27+
def search(filter: SearchFilters = SearchFilters()): F[List[StacItem]] =
28+
logger.trace(s"search: ${filter.asJson.spaces4}") *>
29+
client
30+
.expect(postRequest.withUri(baseUri.withPath("/search")).withEntity(filter.asJson.noSpaces))
31+
.map(_.hcursor.downField("features").as[List[StacItem]].bimap(_ => Nil, identity).merge)
32+
33+
def collections: F[List[StacCollection]] =
34+
logger.trace("collections") *>
35+
client
36+
.expect(getRequest.withUri(baseUri.withPath("/collections")))
37+
.map(_.hcursor.downField("collections").as[List[StacCollection]].bimap(_ => Nil, identity).merge)
38+
39+
def collection(collectionId: NonEmptyString): F[Option[StacCollection]] =
40+
logger.trace(s"collection: $collectionId") *>
41+
client
42+
.expect(getRequest.withUri(baseUri.withPath(s"/collections/$collectionId")))
43+
.map(_.as[Option[StacCollection]].bimap(_ => None, identity).merge)
44+
45+
def items(collectionId: NonEmptyString): F[List[StacItem]] =
46+
logger.trace(s"items: $collectionId") *>
47+
client
48+
.expect(getRequest.withUri(baseUri.withPath(s"/collections/$collectionId/items")))
49+
.map(_.hcursor.downField("features").as[List[StacItem]].bimap(_ => Nil, identity).merge)
50+
51+
def item(collectionId: NonEmptyString, itemId: NonEmptyString): F[Option[StacItem]] =
52+
logger.trace(s"items: $collectionId, $itemId") *>
53+
client
54+
.expect(getRequest.withUri(baseUri.withPath(s"/collections/$collectionId/items/$itemId")))
55+
.map(_.as[Option[StacItem]].bimap(_ => None, identity).merge)
56+
}
57+
58+
object Http4sStacClient {
59+
60+
def apply[F[_]: ConcurrentEffect: Logger](baseUri: Uri)(implicit ec: ExecutionContext): F[Http4sStacClient[F]] = {
61+
BlazeClientBuilder[F](ec).resource.use { client => ConcurrentEffect[F].delay(Http4sStacClient[F](client, baseUri)) }
62+
}
63+
64+
def resource[F[_]: ConcurrentEffect: Logger](
65+
baseUri: Uri
66+
)(implicit ec: ExecutionContext): Resource[F, Http4sStacClient[F]] =
67+
BlazeClientBuilder[F](ec).resource.map(Http4sStacClient[F](_, baseUri))
68+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.azavea.stac4s.api.client
2+
3+
import eu.timepit.refined.types.numeric.PosInt
4+
import io.circe.generic.semiauto._
5+
import io.circe.refined._
6+
import io.circe.{Decoder, Encoder}
7+
8+
import java.time.Instant
9+
10+
final case class PaginationToken(timestampAtLeast: Instant, serialIdGreaterThan: PosInt)
11+
12+
object PaginationToken {
13+
implicit val dec: Decoder[PaginationToken] = deriveDecoder
14+
implicit val enc: Encoder[PaginationToken] = deriveEncoder
15+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.azavea.stac4s.api.client
2+
3+
import com.azavea.stac4s.{Bbox, TemporalExtent}
4+
import io.circe._
5+
import io.circe.generic.semiauto._
6+
import io.circe.refined._
7+
import geotrellis.vector._
8+
import cats.syntax.either._
9+
import cats.syntax.apply._
10+
import cats.instances.either._
11+
import eu.timepit.refined.types.numeric.NonNegInt
12+
13+
import java.time.Instant
14+
15+
case class SearchFilters(
16+
bbox: Option[Bbox] = None,
17+
datetime: Option[TemporalExtent] = None,
18+
intersects: Option[Geometry] = None,
19+
collections: List[String] = Nil,
20+
items: List[String] = Nil,
21+
limit: Option[NonNegInt] = None,
22+
query: JsonObject = JsonObject.empty,
23+
next: Option[PaginationToken] = None
24+
)
25+
26+
object SearchFilters {
27+
28+
// TemporalExtent STAC API compatible serialization
29+
private def stringToInstant(s: String): Either[Throwable, Instant] =
30+
Either.catchNonFatal(Instant.parse(s))
31+
32+
private def temporalExtentToString(te: TemporalExtent): String =
33+
te.value match {
34+
case Some(start) :: Some(end) :: _ if start != end => s"${start.toString}/${end.toString}"
35+
case Some(start) :: Some(end) :: _ if start == end => s"${start.toString}"
36+
case Some(start) :: None :: _ => s"${start.toString}/.."
37+
case None :: Some(end) :: _ => s"../${end.toString}"
38+
}
39+
40+
private def temporalExtentFromString(str: String): Either[String, TemporalExtent] = {
41+
str.split("/").toList match {
42+
case ".." :: endString :: _ =>
43+
val parsedEnd = stringToInstant(endString)
44+
parsedEnd match {
45+
case Left(_) => s"Could not decode instant: $str".asLeft
46+
case Right(end: Instant) => TemporalExtent(None, end).asRight
47+
}
48+
case startString :: ".." :: _ =>
49+
val parsedStart = stringToInstant(startString)
50+
parsedStart match {
51+
case Left(_) => s"Could not decode instant: $str".asLeft
52+
case Right(start: Instant) => TemporalExtent(start, None).asRight
53+
}
54+
case startString :: endString :: _ =>
55+
val parsedStart = stringToInstant(startString)
56+
val parsedEnd = stringToInstant(endString)
57+
(parsedStart, parsedEnd).tupled match {
58+
case Left(_) => s"Could not decode instant: $str".asLeft
59+
case Right((start: Instant, end: Instant)) => TemporalExtent(start, end).asRight
60+
}
61+
case _ =>
62+
Either.catchNonFatal(Instant.parse(str)) match {
63+
case Left(_) => s"Could not decode instant: $str".asLeft
64+
case Right(t: Instant) => TemporalExtent(t, t).asRight
65+
}
66+
}
67+
}
68+
69+
implicit val encoderTemporalExtent: Encoder[TemporalExtent] =
70+
Encoder.encodeString.contramap[TemporalExtent](temporalExtentToString)
71+
72+
implicit val decoderTemporalExtent: Decoder[TemporalExtent] =
73+
Decoder.decodeString.emap(temporalExtentFromString)
74+
75+
implicit val searchFilterDecoder: Decoder[SearchFilters] = { c =>
76+
for {
77+
bbox <- c.downField("bbox").as[Option[Bbox]]
78+
datetime <- c.downField("datetime").as[Option[TemporalExtent]]
79+
intersects <- c.downField("intersects").as[Option[Geometry]]
80+
collectionsOption <- c.downField("collections").as[Option[List[String]]]
81+
itemsOption <- c.downField("items").as[Option[List[String]]]
82+
limit <- c.downField("limit").as[Option[NonNegInt]]
83+
query <- c.get[Option[JsonObject]]("query")
84+
paginationToken <- c.get[Option[PaginationToken]]("next")
85+
} yield {
86+
SearchFilters(
87+
bbox,
88+
datetime,
89+
intersects,
90+
collectionsOption.getOrElse(Nil),
91+
itemsOption.getOrElse(Nil),
92+
limit,
93+
query.getOrElse(JsonObject.empty),
94+
paginationToken
95+
)
96+
}
97+
}
98+
99+
implicit val searchFilterEncoder: Encoder[SearchFilters] = deriveEncoder
100+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2020 Azavea
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.azavea.stac4s.api.client
18+
19+
import com.azavea.stac4s._
20+
import eu.timepit.refined.types.string.NonEmptyString
21+
22+
trait StacClient[F[_]] {
23+
def search(filter: SearchFilters = SearchFilters()): F[List[StacItem]]
24+
def collections: F[List[StacCollection]]
25+
def collection(collectionId: NonEmptyString): F[Option[StacCollection]]
26+
def items(collectionId: NonEmptyString): F[List[StacItem]]
27+
def item(collectionId: NonEmptyString, itemId: NonEmptyString): F[Option[StacItem]]
28+
}

0 commit comments

Comments
 (0)