Skip to content

Commit

Permalink
View collection item footprints as MVT layer (#203)
Browse files Browse the repository at this point in the history
* Upgrade postgres to 12 with postgis 3

* Update collection item tiles endpoint name

* wip

* Add tiles links to collections

* Rename TileRequest -> RasterTileRequest

* Serve MVT byte array from collection footprint endpoint

* print to help debug

* Include missing path segment

* Project tile envelope to source srid

* Work out projection adventures

* Remove unused

* scalafix / scalafmt

* Don't interpolate uninterpolated string

* Add TileJSON endpoint for collection footprints

* Actually use the footprint tile json route

* Switch x and y order?

* Revert "Switch x and y order?"

This reverts commit 88d713d.

* Switch scheme?

* Remove println, scalafmt / fix
  • Loading branch information
jisantuc authored Apr 24, 2020
1 parent 07b9915 commit 23c4884
Show file tree
Hide file tree
Showing 15 changed files with 420 additions and 83 deletions.
11 changes: 8 additions & 3 deletions application/src/main/scala/com/azavea/franklin/api/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,20 @@ $$$$
apiConfig.enableTransactions,
apiConfig.enableTiles
)
allEndpoints = LandingPageEndpoints.endpoints ++ CollectionEndpoints.endpoints ++ collectionItemEndpoints.endpoints ++ SearchEndpoints.endpoints ++ new TileEndpoints(
collectionEndpoints = new CollectionEndpoints(
apiConfig.enableTiles
)
allEndpoints = LandingPageEndpoints.endpoints ++ collectionEndpoints.endpoints ++ collectionItemEndpoints.endpoints ++ SearchEndpoints.endpoints ++ new TileEndpoints(
apiConfig.enableTiles
).endpoints
docs = allEndpoints.toOpenAPI("Franklin", "0.0.1")
docRoutes = new SwaggerHttp4s(docs.toYaml, "open-api", "spec.yaml").routes[IO]
landingPageRoutes = new LandingPageService[IO](apiConfig).routes
searchRoutes = new SearchService[IO](apiConfig.apiHost, apiConfig.enableTiles, xa).routes
tileRoutes = new TileService[IO](apiConfig.enableTiles, xa).routes
collectionRoutes = new CollectionsService[IO](xa).routes <+> new CollectionItemsService[IO](
tileRoutes = new TileService[IO](apiConfig.apiHost, apiConfig.enableTiles, xa).routes
collectionRoutes = new CollectionsService[IO](xa, apiConfig.apiHost, apiConfig.enableTiles).routes <+> new CollectionItemsService[
IO
](
xa,
apiConfig.apiHost,
apiConfig.enableTransactions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import sttp.model.StatusCode.{NotFound => NF}
import sttp.tapir._
import sttp.tapir.json.circe._

object CollectionEndpoints {
class CollectionEndpoints(enableTiles: Boolean) {

val base = endpoint.in("collections")

Expand All @@ -24,5 +24,16 @@ object CollectionEndpoints {
.description("A single collection")
.name("collectionUnique")

val endpoints = List(collectionsList, collectionUnique)
val collectionTiles: Endpoint[String, NotFound, (Json, String), Nothing] =
base.get
.in(path[String] / "tiles")
.out(jsonBody[Json])
.errorOut(oneOf(statusMapping(NF, jsonBody[NotFound].description("not found"))))
.out(header[String]("ETag"))
.description("A collection's tile endpoints")
.name("collectionTiles")

val endpoints = List(collectionsList, collectionUnique) ++ {
if (enableTiles) List(collectionTiles) else Nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class CollectionItemEndpoints(enableTransactions: Boolean, enableTiles: Boolean)
.errorOut(oneOf(statusMapping(NF, jsonBody[NotFound].description("not found"))))
.out(header[String]("ETag"))
.description("An item's tile endpoints")
.name("collectionItemUnique")
.name("collectionItemTiles")

val postItem: Endpoint[(String, StacItem), ValidationError, (Json, String), Nothing] =
base.post
Expand Down
Original file line number Diff line number Diff line change
@@ -1,74 +1,71 @@
package com.azavea.franklin.api.endpoints

import com.azavea.franklin.datamodel.{
ItemRasterTileRequest,
MapboxVectorTileFootprintRequest,
Quantile
}
import com.azavea.franklin.error.NotFound
import io.circe.Json
import sttp.model.StatusCode.{NotFound => NF}
import sttp.tapir._
import sttp.tapir.codec.refined._
import sttp.tapir.json.circe._

import java.net.URLDecoder
import java.nio.charset.StandardCharsets

case class TileRequest(
collectionRaw: String,
itemRaw: String,
z: Int,
x: Int,
y: Int,
asset: String,
redBandOption: Option[Int],
greenBandOption: Option[Int],
blueBandOption: Option[Int],
upperQuantileOption: Option[Quantile],
lowerQuantileOption: Option[Quantile]
) {

val collection = URLDecoder.decode(collectionRaw, StandardCharsets.UTF_8.toString)
val item = URLDecoder.decode(itemRaw, StandardCharsets.UTF_8.toString)

val redBand = redBandOption.getOrElse(0)
val greenBand = greenBandOption.getOrElse(1)
val blueBand = blueBandOption.getOrElse(2)

val bands = Seq(redBand, greenBand, blueBand)

// Because lists are 0 indexed and humans are 1 indexed we need to adjust
val upperQuantile = upperQuantileOption.map(_.value).getOrElse(100) - 1
val lowerQuantile = lowerQuantileOption.map(_.value).getOrElse(-1) + 1

val zxy = (z, x, y)

}

class TileEndpoints(enableTiles: Boolean) {

val basePath = "tiles" / "collections"
val zxyPath = path[Int] / path[Int] / path[Int]

val tilePath: EndpointInput[(String, String, Int, Int, Int)] =
val itemRasterTilePath: EndpointInput[(String, String, Int, Int, Int)] =
(basePath / path[String] / "items" / path[String] / "WebMercatorQuad" / zxyPath)

val tileParameters: EndpointInput[TileRequest] =
tilePath
val collectionFootprintTilePath: EndpointInput[(String, Int, Int, Int)] =
(basePath / path[String] / "footprint" / "WebMercatorQuad" / zxyPath)

val collectionFootprintTileJsonPath: EndpointInput[String] =
(basePath / path[String] / "footprint" / "tile-json")

val itemRasterTileParameters: EndpointInput[ItemRasterTileRequest] =
itemRasterTilePath
.and(query[String]("asset"))
.and(query[Option[Int]]("redBand"))
.and(query[Option[Int]]("greenBand"))
.and(query[Option[Int]]("blueBand"))
.and(query[Option[Quantile]]("upperQuantile"))
.and(query[Option[Quantile]]("lowerQuantile"))
.mapTo(TileRequest)
.mapTo(ItemRasterTileRequest)

val tileEndpoint: Endpoint[TileRequest, NotFound, Array[Byte], Nothing] =
val itemRasterTileEndpoint: Endpoint[ItemRasterTileRequest, NotFound, Array[Byte], Nothing] =
endpoint.get
.in(tileParameters)
.in(itemRasterTileParameters)
.out(binaryBody[Array[Byte]])
.out(header("content-type", "image/png"))
.errorOut(oneOf(statusMapping(NF, jsonBody[NotFound].description("not found"))))
.description("Raster Tile endpoint for Collection Item")
.name("collectionItemTiles")

val collectionFootprintTileEndpoint
: Endpoint[MapboxVectorTileFootprintRequest, NotFound, Array[Byte], Nothing] =
endpoint.get
.in(collectionFootprintTilePath.mapTo(MapboxVectorTileFootprintRequest))
.out(binaryBody[Array[Byte]])
.out(header("content-type", "application/vnd.mapbox-vector-tile"))
.errorOut(oneOf(statusMapping(NF, jsonBody[NotFound].description("not found"))))
.description("MVT endpoint for a collection's footprint")
.name("collectionFootprintTiles")

val collectionFootprintTileJson: Endpoint[String, NotFound, Json, Nothing] =
endpoint.get
.in(collectionFootprintTileJsonPath)
.out(jsonBody[Json])
.errorOut(oneOf(statusMapping(NF, jsonBody[NotFound].description("not found"))))
.description("TileJSON representation of this collection's footprint tiles")
.name("collectionFootprintTileJSON")

val endpoints = enableTiles match {
case true => List(tileEndpoint)
case _ => List.empty
case true =>
List(itemRasterTileEndpoint, collectionFootprintTileEndpoint, collectionFootprintTileJson)
case _ => List.empty
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,21 @@ package object implicits {
}
}

implicit class StacCollectionWithTiles(collection: StacCollection) {

def addTilesLink(apiHost: String): StacCollection = {
val tileLink = StacLink(
s"$apiHost/collections/${collection.id}/tiles",
StacLinkType.VendorLinkType("tiles"),
Some(`application/json`),
Some("Tile URLs for Collection"),
List.empty
)
collection.copy(links = tileLink :: collection.links)
}

def maybeAddTilesLink(enableTiles: Boolean, apiHost: String) =
if (enableTiles) addTilesLink(apiHost) else collection
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,37 @@ package com.azavea.franklin.api.services
import cats.effect._
import cats.implicits._
import com.azavea.franklin.api.endpoints.CollectionEndpoints
import com.azavea.franklin.api.implicits._
import com.azavea.franklin.database.StacCollectionDao
import com.azavea.franklin.datamodel.CollectionsResponse
import com.azavea.franklin.datamodel.{CollectionsResponse, TileInfo}
import com.azavea.franklin.error.{NotFound => NF}
import doobie._
import doobie.implicits._
import doobie.util.transactor.Transactor
import eu.timepit.refined.auto._
import eu.timepit.refined.types.string.NonEmptyString
import io.circe._
import io.circe.syntax._
import org.http4s._
import org.http4s.dsl.Http4sDsl
import sttp.tapir.server.http4s._

import java.net.URLDecoder
import java.nio.charset.StandardCharsets

class CollectionsService[F[_]: Sync](xa: Transactor[F])(implicit contextShift: ContextShift[F])
extends Http4sDsl[F] {
class CollectionsService[F[_]: Sync](
xa: Transactor[F],
apiHost: NonEmptyString,
enableTiles: Boolean
)(
implicit contextShift: ContextShift[F]
) extends Http4sDsl[F] {

def listCollections(): F[Either[Unit, Json]] = {
for {
collections <- StacCollectionDao.listCollections().transact(xa)
updated = collections map { _.maybeAddTilesLink(enableTiles, apiHost) }
} yield {
Either.right(CollectionsResponse(collections).asJson)
Either.right(CollectionsResponse(updated).asJson)
}

}
Expand All @@ -38,16 +45,44 @@ class CollectionsService[F[_]: Sync](xa: Transactor[F])(implicit contextShift: C
.getCollectionUnique(collectionId)
.transact(xa)
} yield {
collectionOption match {
case Some(collection) => Either.right(collection.asJson)
case _ => Either.left(NF(s"Collection $collectionId not found"))
}
Either.fromOption(
collectionOption map { _.maybeAddTilesLink(enableTiles, apiHost).asJson },
NF(s"Collection $collectionId not found")
)
}
}

val routes: HttpRoutes[F] =
CollectionEndpoints.collectionsList.toRoutes(_ => listCollections()) <+> CollectionEndpoints.collectionUnique
def getCollectionTiles(rawCollectionId: String): F[Either[NF, (Json, String)]] = {
val collectionId = URLDecoder.decode(rawCollectionId, StandardCharsets.UTF_8.toString)
for {
collectionOption <- StacCollectionDao
.getCollectionUnique(collectionId)
.transact(xa)
} yield {
Either.fromOption(
collectionOption.map(collection =>
(
TileInfo.fromStacCollection(apiHost, collection).asJson,
collection.##.toString
)
),
NF(s"Collection $collectionId")
)
}
}

val collectionEndpoints = new CollectionEndpoints(enableTiles)

val routesList = List(
collectionEndpoints.collectionsList.toRoutes(_ => listCollections()),
collectionEndpoints.collectionUnique
.toRoutes {
case collectionId => getCollectionUnique(collectionId)
}
) ++ (if (enableTiles) {
List(collectionEndpoints.collectionTiles.toRoutes(getCollectionTiles))
} else Nil)

val routes = routesList.foldK

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,35 @@ import cats.data._
import cats.effect._
import cats.implicits._
import com.azavea.franklin.api.endpoints._
import com.azavea.franklin.database.StacCollectionDao
import com.azavea.franklin.database.StacItemDao
import com.azavea.franklin.datamodel.{
ItemRasterTileRequest,
MapboxVectorTileFootprintRequest,
TileJson
}
import com.azavea.franklin.error.{NotFound => NF}
import com.azavea.franklin.tile._
import doobie._
import doobie.implicits._
import geotrellis.raster._
import eu.timepit.refined.types.string.NonEmptyString
import geotrellis.raster.{io => _, _}
import geotrellis.server.LayerTms
import geotrellis.server._
import io.circe.Json
import io.circe.syntax._
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import sttp.tapir.server.http4s._

class TileService[F[_]: Sync: LiftIO](enableTiles: Boolean, xa: Transactor[F])(
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

class TileService[F[_]: Sync: LiftIO](
serverHost: NonEmptyString,
enableTiles: Boolean,
xa: Transactor[F]
)(
implicit cs: ContextShift[F],
csIO: ContextShift[IO]
) extends Http4sDsl[F] {
Expand All @@ -26,7 +42,7 @@ class TileService[F[_]: Sync: LiftIO](enableTiles: Boolean, xa: Transactor[F])(

val tileEndpoints = new TileEndpoints(enableTiles)

def getTile(tileRequest: TileRequest): F[Either[NF, Array[Byte]]] = {
def getItemRasterTile(tileRequest: ItemRasterTileRequest): F[Either[NF, Array[Byte]]] = {
val assetKey = tileRequest.asset
val collectionId = tileRequest.collection
val itemId = tileRequest.item
Expand Down Expand Up @@ -78,9 +94,36 @@ class TileService[F[_]: Sync: LiftIO](enableTiles: Boolean, xa: Transactor[F])(
tileEither.value
}

val routes: HttpRoutes[F] = tileEndpoints.tileEndpoint.get.toRoutes {
case (tileRequest) => {
getTile(tileRequest)
def getCollectionFootprintTile(
tileRequest: MapboxVectorTileFootprintRequest
): F[Either[NF, Array[Byte]]] =
for {
mvt <- StacCollectionDao.getCollectionFootprintTile(tileRequest).transact(xa)
} yield {
Either.fromOption(
mvt,
NF(s"Could not produce tile for bounds: ${tileRequest.z}/${tileRequest.x}/${tileRequest.y}")
)
}

def getCollectionFootprintTileJson(
collectionId: String
): F[Either[NF, Json]] = {
val decoded = URLDecoder.decode(collectionId, StandardCharsets.UTF_8.toString)
for {
collectionO <- StacCollectionDao.getCollectionUnique(decoded).transact(xa)
} yield {
Either.fromOption(
collectionO map { collection =>
TileJson.fromStacCollection(collection, serverHost).asJson
},
NF(s"Could not produce tile json for collection: $decoded")
)
}
}

val routes: HttpRoutes[F] = tileEndpoints.itemRasterTileEndpoint.toRoutes(getItemRasterTile) <+>
tileEndpoints.collectionFootprintTileEndpoint.toRoutes(getCollectionFootprintTile) <+>
tileEndpoints.collectionFootprintTileJson.toRoutes(getCollectionFootprintTileJson)

}
Loading

0 comments on commit 23c4884

Please sign in to comment.