diff --git a/application/src/main/scala/com/azavea/franklin/datamodel/PaginationToken.scala b/application/src/main/scala/com/azavea/franklin/datamodel/PaginationToken.scala index 0d80e01cc..7199cfc0d 100644 --- a/application/src/main/scala/com/azavea/franklin/datamodel/PaginationToken.scala +++ b/application/src/main/scala/com/azavea/franklin/datamodel/PaginationToken.scala @@ -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._ @@ -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 } + } diff --git a/application/src/test/scala/com/azavea/franklin/api/TestClient.scala b/application/src/test/scala/com/azavea/franklin/api/TestClient.scala index 3e470b7b0..03564a7e5 100644 --- a/application/src/test/scala/com/azavea/franklin/api/TestClient.scala +++ b/application/src/test/scala/com/azavea/franklin/api/TestClient.scala @@ -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 } diff --git a/application/src/test/scala/com/azavea/franklin/api/services/FiltersFor.scala b/application/src/test/scala/com/azavea/franklin/api/services/FiltersFor.scala index 72790a970..c77adca86 100644 --- a/application/src/test/scala/com/azavea/franklin/api/services/FiltersFor.scala +++ b/application/src/test/scala/com/azavea/franklin/api/services/FiltersFor.scala @@ -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 + } } diff --git a/application/src/test/scala/com/azavea/franklin/api/services/SearchServiceSpec.scala b/application/src/test/scala/com/azavea/franklin/api/services/SearchServiceSpec.scala index d68afcb7f..39a5216f0 100644 --- a/application/src/test/scala/com/azavea/franklin/api/services/SearchServiceSpec.scala +++ b/application/src/test/scala/com/azavea/franklin/api/services/SearchServiceSpec.scala @@ -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} @@ -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) @@ -117,6 +120,50 @@ 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.from(1).toOption) + val result1 = getSearchCollection(inclusiveParams) + + val result2 = result1 + .flatMap { + _.flatTraverse { r => + /** This line intentionally decodes next token [[String]] into [[PaginationToken]] */ + 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)) + } + } + + (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))