Skip to content
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

Fix PaginationToken Circe codecs #752

Merged
merged 3 commits into from
May 25, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.azavea.franklin.datamodel

import cats.syntax.either._
import com.azavea.stac4s.meta._
import eu.timepit.refined.types.numeric.PosInt
import io.circe.Error
import io.circe.generic.semiauto._
import io.circe.parser._
import io.circe.refined._
Expand Down Expand Up @@ -31,23 +33,30 @@ object PaginationToken {
}
}

implicit val dec: Decoder[PaginationToken] = deriveDecoder
implicit val enc: Encoder[PaginationToken] = deriveEncoder
val defaultDecoder: Decoder[PaginationToken] = deriveDecoder
val defaultEncoder: Encoder[PaginationToken] = deriveEncoder

val b64Encoder = Base64.getEncoder()
val b64Decoder = Base64.getDecoder()
val b64Encoder: Base64.Encoder = Base64.getEncoder
val b64Decoder: Base64.Decoder = Base64.getDecoder

def encPaginationToken(token: PaginationToken): String = b64Encoder.encodeToString(
token.asJson.noSpaces.getBytes
token.asJson(defaultEncoder).noSpaces.getBytes
)

def decPaginationToken(encoded: String): DecodeResult[PaginationToken] = {
val jsonString: String = new String(b64Decoder.decode(encoded))
val circeResult = for {
def decPaginationTokenEither(encoded: String): Either[Error, PaginationToken] = {
val jsonString = new String(b64Decoder.decode(encoded))
for {
js <- parse(jsonString)
decoded <- js.as[PaginationToken]
decoded <- js.as[PaginationToken](defaultDecoder)
} yield decoded
circeResult.toDecodeResult
}

def decPaginationToken(encoded: String): DecodeResult[PaginationToken] =
decPaginationTokenEither(encoded).toDecodeResult

implicit val paginationTokenDecoder: Decoder[PaginationToken] =
Decoder.decodeString.emap(str => decPaginationTokenEither(str).leftMap(_.getMessage))

implicit val paginationTokenEncoder: Encoder[PaginationToken] = { encPaginationToken(_).asJson }

}
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,10 @@ class TestClient[F[_]: Sync](
collection: StacCollection
): Resource[F, (StacCollection, StacItem)] =
(getCollectionResource(collection), getItemResource(collection, item)).tupled

def getCollectionItemsResource(
items: List[StacItem],
collection: StacCollection
): Resource[F, (StacCollection, List[StacItem])] =
(getCollectionResource(collection), items.traverse(getItemResource(collection, _))).tupled
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,13 @@ object FiltersFor {
// just to cooperate with timeFilterFor
concatenated.get
}

def inclusiveFilters(collection: StacCollection): SearchFilters = {
val filters: NonEmptyList[Option[SearchFilters]] =
NonEmptyList.of(collectionFilterFor(collection).some)
val concatenated = filters.combineAll
// guaranteed to succeed, since most of the filters are being converted into options
// just to cooperate with timeFilterFor
concatenated.get
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import cats.syntax.all._
import com.azavea.franklin.Generators
import com.azavea.franklin.api.{TestClient, TestServices}
import com.azavea.franklin.database.{SearchFilters, TestDatabaseSpec}
import com.azavea.franklin.datamodel.StacSearchCollection
import com.azavea.franklin.datamodel.{PaginationToken, StacSearchCollection}
import com.azavea.stac4s.testing.JvmInstances._
import com.azavea.stac4s.testing._
import com.azavea.stac4s.{StacCollection, StacItem}
import com.azavea.stac4s.{StacCollection, StacItem, StacLinkType}
import eu.timepit.refined.types.numeric.NonNegInt
import io.circe.syntax._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.{Method, Request, Uri}
Expand All @@ -24,14 +26,15 @@ class SearchServiceSpec
This specification verifies that the Search Service sensibly finds and excludes items

The search service should:
- search with POST search filters $postSearchFiltersExpectation
- search with GET search filters $getSearchFiltersExpectation
- find an item with filters designed to find it $findItemWhenExpected
- not find items when excluded by time $dontFindTimeFilters
- not find items when excluded by bbox $dontFindBboxFilters
- not find items when excluded by intersection $dontFindGeomFilters
- not find items when excluded by collection $dontFindCollectionFilters
- not find items when excluded by item $dontFindItemFilters
- search with POST search filters $postSearchFiltersExpectation
- search with GET search filters $getSearchFiltersExpectation
- find an item with filters designed to find it $findItemWhenExpected
- find two items with filters designed to find it $find2ItemsWhenExpected
- not find items when excluded by time $dontFindTimeFilters
- not find items when excluded by bbox $dontFindBboxFilters
- not find items when excluded by intersection $dontFindGeomFilters
- not find items when excluded by collection $dontFindCollectionFilters
- not find items when excluded by item $dontFindItemFilters
"""
val testServices = new TestServices[IO](transactor)

Expand Down Expand Up @@ -117,6 +120,51 @@ class SearchServiceSpec
result.features.head.id should beEqualTo(stacItem.id)
}

def find2ItemsWhenExpected = prop {
(stacItem1: StacItem, stacItem2: StacItem, stacCollection: StacCollection) =>
val resourceIO = testClient map {
_.getCollectionItemsResource(stacItem1 :: stacItem2 :: Nil, stacCollection)
}
val requestIO = resourceIO flatMap { resource =>
def getSearchCollection(searchFilters: SearchFilters): IO[Option[StacSearchCollection]] = {
val request =
Request[IO](method = Method.POST, uri = Uri.unsafeFromString(s"/search"))
.withEntity(searchFilters.asJson)
(for {
resp <- testServices.searchService.routes.run(request)
decoded <- OptionT.liftF { resp.as[StacSearchCollection] }
} yield decoded).value
}

resource.use {
case (collection, _) =>
val inclusiveParams =
FiltersFor.inclusiveFilters(collection).copy(limit = NonNegInt.unsafeFrom(1).some)
pomadchin marked this conversation as resolved.
Show resolved Hide resolved
val result1 = getSearchCollection(inclusiveParams)

val result2 = result1
.flatMap {
_.traverse { r =>
pomadchin marked this conversation as resolved.
Show resolved Hide resolved
/** This line intentionally decodes next token [[String]] into [[PaginationToken]] */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a pretty important comment

val next = r.links.collectFirst {
case l if l.rel == StacLinkType.Next =>
l.href.split("next=").last.asJson.as[PaginationToken].toOption
}.flatten
getSearchCollection(inclusiveParams.copy(next = next))
}
}
.map(_.flatten)

(result1, result2).tupled
}
}

val (Some(result1), Some(result2)) = requestIO.unsafeRunSync()

result1.features.head.id should beEqualTo(stacItem1.id)
result2.features.head.id should beEqualTo(stacItem2.id)
}

def dontFindTimeFilters =
getExclusionTest("temporal extent")(_ => item => FiltersFor.timeFilterExcluding(item))

Expand Down