diff --git a/acceptance-tests/src/test/scala/io/renku/graph/acceptancetests/knowledgegraph/LineageResourcesSpec.scala b/acceptance-tests/src/test/scala/io/renku/graph/acceptancetests/knowledgegraph/LineageResourcesSpec.scala index 66c20ab6da..1d99a0c7db 100644 --- a/acceptance-tests/src/test/scala/io/renku/graph/acceptancetests/knowledgegraph/LineageResourcesSpec.scala +++ b/acceptance-tests/src/test/scala/io/renku/graph/acceptancetests/knowledgegraph/LineageResourcesSpec.scala @@ -36,6 +36,7 @@ import io.renku.graph.model.testentities.LineageExemplarData.ExemplarData import io.renku.graph.model.testentities.generators.EntitiesGenerators.{personEntities, renkuProjectEntities, visibilityPublic} import io.renku.graph.model.testentities.{LineageExemplarData, NodeDef} import io.renku.http.client.AccessToken +import io.renku.http.client.UrlEncoder.urlEncode import io.renku.jsonld.syntax._ import org.http4s.Status.{NotFound, Ok} import org.scalatest.GivenWhenThen @@ -85,7 +86,7 @@ class LineageResourcesSpec When("user calls the lineage endpoint") val response = - knowledgeGraphClient GET s"knowledge-graph/projects/${project.path}/files/${exemplarData.`grid_plot entity`.location}/lineage" + knowledgeGraphClient GET s"knowledge-graph/projects/${project.path}/files/${urlEncode(exemplarData.`grid_plot entity`.location)}/lineage" Then("they should get Ok response with project lineage in Json") response.status shouldBe Ok @@ -121,7 +122,7 @@ class LineageResourcesSpec When("user fetches the lineage of the project he is a member of") val response = - knowledgeGraphClient GET (s"knowledge-graph/projects/${project.path}/files/${accessibleExemplarData.`grid_plot entity`.location}/lineage", user.accessToken) + knowledgeGraphClient GET (s"knowledge-graph/projects/${project.path}/files/${urlEncode(accessibleExemplarData.`grid_plot entity`.location)}/lineage", user.accessToken) Then("he should get OK response with project lineage in Json") response.status shouldBe Ok @@ -150,7 +151,7 @@ class LineageResourcesSpec When("user posts a graphql query to fetch lineage of the project he is not a member of") val response = knowledgeGraphClient.GET( - s"knowledge-graph/projects/${project.path}/files/${privateExemplarData.`grid_plot entity`.location}/lineage" + s"knowledge-graph/projects/${project.path}/files/${urlEncode(privateExemplarData.`grid_plot entity`.location)}/lineage" ) Then("he should get a NotFound response without lineage") diff --git a/generators/src/main/scala/io/renku/generators/Generators.scala b/generators/src/main/scala/io/renku/generators/Generators.scala index 65036e4666..9e89e83d5f 100644 --- a/generators/src/main/scala/io/renku/generators/Generators.scala +++ b/generators/src/main/scala/io/renku/generators/Generators.scala @@ -44,6 +44,17 @@ object Generators { def noDashUuid: Gen[String] = uuid.map(_.toString.replace("-", "")) + def randomiseCases(value: String): Gen[String] = { + for { + itemsNo <- positiveInts(value.length).map(_.value) + itemsIndices <- Gen.pick(itemsNo, value.zipWithIndex.map(_._2)) + } yield value.zipWithIndex.foldLeft(List.empty[Char]) { + case (newChars, char -> idx) if itemsIndices contains idx => + (if (char.isLower) char.toUpper else char.toLower) :: newChars + case (newChars, char -> _) => char :: newChars + } + }.map(_.reverse.mkString) + def nonEmptyStrings(maxLength: Int = 10, charsGenerator: Gen[Char] = alphaChar): Gen[String] = { require(maxLength > 0) nonBlankStrings(maxLength = Refined.unsafeApply(maxLength), charsGenerator = charsGenerator) map (_.value) diff --git a/knowledge-graph/README.md b/knowledge-graph/README.md index 30eeda83ea..d40e335d05 100644 --- a/knowledge-graph/README.md +++ b/knowledge-graph/README.md @@ -652,7 +652,7 @@ Response body example: #### GET /knowledge-graph/projects/:namespace/:name/files/:location/lineage -Fetches lineage for a given project `namespace`/`name` and file `location` (relative path). This endpoint is intended to replace the graphql endpoint. +Fetches lineage for a given project `namespace`/`name` and file `location` (URL-encoded relative path to the file). This endpoint is intended to replace the graphql endpoint. | Status | Description | |----------------------------|-------------------------------------------------------------------| diff --git a/knowledge-graph/src/main/scala/io/renku/knowledgegraph/MicroserviceRoutes.scala b/knowledge-graph/src/main/scala/io/renku/knowledgegraph/MicroserviceRoutes.scala index d6f914c332..1d81e35b0e 100644 --- a/knowledge-graph/src/main/scala/io/renku/knowledgegraph/MicroserviceRoutes.scala +++ b/knowledge-graph/src/main/scala/io/renku/knowledgegraph/MicroserviceRoutes.scala @@ -29,6 +29,8 @@ import io.renku.control.{RateLimit, Throttler} import io.renku.graph.http.server.security._ import io.renku.graph.model import io.renku.graph.model.persons +import io.renku.http.InfoMessage +import io.renku.http.InfoMessage._ import io.renku.http.client.GitLabClient import io.renku.http.rest.SortBy.Direction import io.renku.http.rest.paging.PagingRequest @@ -41,13 +43,12 @@ import io.renku.http.server.version import io.renku.knowledgegraph.datasets.rest.DatasetsSearchEndpoint.Query.Phrase import io.renku.knowledgegraph.datasets.rest._ import io.renku.knowledgegraph.graphql.QueryEndpoint -import io.renku.knowledgegraph.lineage.model.Node.Location import io.renku.knowledgegraph.projects.rest.ProjectEndpoint import io.renku.metrics.{MetricsRegistry, RoutesMetrics} import io.renku.rdfstore.SparqlQueryTimeRecorder import org.http4s.dsl.Http4sDsl import org.http4s.server.AuthMiddleware -import org.http4s.{AuthedRoutes, ParseFailure, Request, Response} +import org.http4s.{AuthedRoutes, ParseFailure, Request, Response, Status, Uri} import org.typelevel.log4cats.Logger import scala.concurrent.ExecutionContext @@ -209,12 +210,8 @@ private class MicroserviceRoutes[F[_]: MonadThrow]( .flatTap(authorizePath(_, maybeAuthUser).leftMap(_.toHttpResponse)) .semiflatMap(getProjectDatasets) .merge - case LineageEndpoint(projectPathParts, locationParts) => - (projectPathParts.toProjectPath -> locationParts.toLocation) - .mapN(_ -> _) - .flatTap { case (projectPath, _) => authorizePath(projectPath, maybeAuthUser).leftMap(_.toHttpResponse) } - .semiflatMap { case (projectPath, location) => `GET /lineage`(projectPath, location, maybeAuthUser) } - .merge + case projectPathParts :+ "files" :+ location :+ "lineage" => + getLineage(projectPathParts, location, maybeAuthUser) case projectPathParts => projectPathParts.toProjectPath .flatTap(authorizePath(_, maybeAuthUser).leftMap(_.toHttpResponse)) @@ -222,36 +219,28 @@ private class MicroserviceRoutes[F[_]: MonadThrow]( .merge } - object LineageEndpoint { - def unapply(segments: List[String]): Option[(List[String], List[String])] = - if (segments.contains("lineage") && segments.contains("files")) { + private def getLineage(projectPathParts: List[String], location: String, maybeAuthUser: Option[AuthUser]) = { + import io.renku.knowledgegraph.lineage.model.Node.Location - val indexOfFiles = segments.indexOf("files") - val projectPath = segments.take(indexOfFiles) - val location = segments.drop(indexOfFiles + 1).takeWhile(!_.contains("lineage")) - if (projectPath.nonEmpty && location.nonEmpty) - Some((projectPath, location)) - else None - } else None + def toLocation(location: String): EitherT[F, Response[F], Location] = EitherT.fromEither[F] { + Location + .from(Uri.decode(location)) + .leftMap(_ => Response[F](Status.NotFound).withEntity(InfoMessage("Resource not found"))) + } + + (projectPathParts.toProjectPath -> toLocation(location)) + .mapN(_ -> _) + .flatTap { case (projectPath, _) => authorizePath(projectPath, maybeAuthUser).leftMap(_.toHttpResponse) } + .semiflatMap { case (projectPath, location) => `GET /lineage`(projectPath, location, maybeAuthUser) } + .merge } private implicit class PathPartsOps(parts: List[String]) { - - import io.renku.http.InfoMessage - import io.renku.http.InfoMessage._ - import org.http4s.{Response, Status} - lazy val toProjectPath: EitherT[F, Response[F], model.projects.Path] = EitherT.fromEither[F] { model.projects.Path .from(parts mkString "/") .leftMap(_ => Response[F](Status.NotFound).withEntity(InfoMessage("Resource not found"))) } - - lazy val toLocation: EitherT[F, Response[F], Location] = EitherT.fromEither[F] { - Location - .from(parts mkString "/") - .leftMap(_ => Response[F](Status.NotFound).withEntity(InfoMessage("Resource not found"))) - } } } diff --git a/knowledge-graph/src/main/scala/io/renku/knowledgegraph/entities/finder/package.scala b/knowledge-graph/src/main/scala/io/renku/knowledgegraph/entities/finder/package.scala index 1a80c3aa04..5d94f3097e 100644 --- a/knowledge-graph/src/main/scala/io/renku/knowledgegraph/entities/finder/package.scala +++ b/knowledge-graph/src/main/scala/io/renku/knowledgegraph/entities/finder/package.scala @@ -23,7 +23,7 @@ import cats.syntax.all._ import io.renku.graph.model.projects import io.renku.knowledgegraph.entities.Endpoint.Criteria import io.renku.knowledgegraph.entities.Endpoint.Criteria.Filters -import io.renku.tinytypes.{LocalDateTinyType, TinyType} +import io.renku.tinytypes.{LocalDateTinyType, StringTinyType, TinyType, TinyTypeFactory} import java.time.{Instant, ZoneOffset} @@ -70,7 +70,7 @@ package object finder { filters.creators match { case creators if creators.isEmpty => "" case creators => - s"FILTER (IF (BOUND($variableName), $variableName IN ${creators.map(_.asSparqlEncodedLiteral).mkString("(", ", ", ")")}, false))" + s"FILTER (IF (BOUND($variableName), LCASE($variableName) IN ${creators.map(_.toLowerCase.asSparqlEncodedLiteral).mkString("(", ", ", ")")}, false))" } def maybeOnCreatorsNames(variableName: String): String = @@ -78,7 +78,7 @@ package object finder { case creators if creators.isEmpty => "" case creators => s"""FILTER (IF (BOUND($variableName), ${creators - .map(c => s"CONTAINS($variableName, ${c.asSparqlEncodedLiteral})") + .map(c => s"CONTAINS(LCASE($variableName), ${c.toLowerCase.asSparqlEncodedLiteral})") .mkString(" || ")} , false))""" } @@ -155,6 +155,10 @@ package object finder { lazy val asSparqlEncodedLiteral: String = s"'${sparqlEncode(v.show)}'" lazy val asLiteral: String = show"'$v'" } + + private implicit class StringValueOps[TT <: StringTinyType](v: TT)(implicit s: Show[TT]) { + def toLowerCase(implicit factory: TinyTypeFactory[TT]): TT = factory(v.show.toLowerCase()) + } } private[finder] object DecodingTools { diff --git a/knowledge-graph/src/test/scala/io/renku/knowledgegraph/MicroserviceRoutesSpec.scala b/knowledge-graph/src/test/scala/io/renku/knowledgegraph/MicroserviceRoutesSpec.scala index 25f724fbab..fe927eb77f 100644 --- a/knowledge-graph/src/test/scala/io/renku/knowledgegraph/MicroserviceRoutesSpec.scala +++ b/knowledge-graph/src/test/scala/io/renku/knowledgegraph/MicroserviceRoutesSpec.scala @@ -23,6 +23,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect.{IO, Resource} import cats.syntax.all._ import eu.timepit.refined.auto._ +import io.renku.http.client.UrlEncoder.urlEncode import io.circe.Json import io.renku.generators.CommonGraphGenerators._ import io.renku.generators.Generators.Implicits._ @@ -244,9 +245,10 @@ class MicroserviceRoutesSpec response.body[ErrorMessage] shouldBe InfoMessage(AuthorizationFailure.getMessage) } } + "GET /knowledge-graph/projects/:projectId/files/:location/lineage" should { def lineageUri(projectPath: ProjectPath, location: Location) = - Uri.unsafeFromString(s"knowledge-graph/projects/${projectPath.value}/files/${location.value}/lineage") + Uri.unsafeFromString(s"knowledge-graph/projects/${projectPath.show}/files/${urlEncode(location.show)}/lineage") s"return $Ok when the lineage is found" in new TestCase { val projectPath = projectPaths.generateOne diff --git a/knowledge-graph/src/test/scala/io/renku/knowledgegraph/entities/finder/EntitiesFinderSpec.scala b/knowledge-graph/src/test/scala/io/renku/knowledgegraph/entities/finder/EntitiesFinderSpec.scala index d099aadb78..6684d6fe82 100644 --- a/knowledge-graph/src/test/scala/io/renku/knowledgegraph/entities/finder/EntitiesFinderSpec.scala +++ b/knowledge-graph/src/test/scala/io/renku/knowledgegraph/entities/finder/EntitiesFinderSpec.scala @@ -350,6 +350,35 @@ class EntitiesFinderSpec extends AnyWordSpec with FinderSpecOps with should.Matc ).sortBy(_.name.value) } + "return entities creator matches in a case-insensitive way" in new TestCase { + val creator = personEntities.generateOne + + val soleProject = renkuProjectEntities(visibilityPublic) + .modify(creatorLens.modify(_ => creator.some)) + .generateOne + + val dsAndProject @ _ ::~ dsProject = renkuProjectEntities(visibilityPublic) + .addDataset( + datasetEntities(provenanceNonModified).modify( + provenanceLens.modify( + creatorsLens.modify(_ => NonEmptyList.of(personEntities.generateOne, creator)) + ) + ) + ) + .generateOne + + loadToStore(soleProject, dsProject) + + finder + .findEntities(Criteria(Filters(creators = Set(randomiseCases(creator.name.show).generateAs(persons.Name))))) + .unsafeRunSync() + .results shouldBe List( + soleProject.to[model.Entity.Project], + dsAndProject.to[model.Entity.Dataset], + creator.to[model.Entity.Person] + ).sortBy(_.name.value) + } + "return entities that matches at least one of the given creators" in new TestCase { val projectCreator = personEntities.generateOne @@ -381,7 +410,7 @@ class EntitiesFinderSpec extends AnyWordSpec with FinderSpecOps with should.Matc ).sortBy(_.name.value) } - "return no entities when no match on creator" in new TestCase { + "return no entities when there's no match on creator" in new TestCase { val _ ::~ project = renkuProjectEntities(visibilityPublic) .addDataset(datasetEntities(provenanceNonModified)) .generateOne diff --git a/renku-model/src/test/scala/io/renku/graph/model/infraSpec.scala b/renku-model/src/test/scala/io/renku/graph/model/infraSpec.scala index 30979b283d..1d06f977d9 100644 --- a/renku-model/src/test/scala/io/renku/graph/model/infraSpec.scala +++ b/renku-model/src/test/scala/io/renku/graph/model/infraSpec.scala @@ -71,10 +71,12 @@ class CliVersionSpec extends AnyWordSpec with ScalaCheckPropertyChecks with shou "consider the major only if different" in { forAll(cliVersions, cliVersions) { (version1, version2) => - val list = List(version1, version2) + whenever(version1.major != version2.major) { + val list = List(version1, version2) - if (version1.major < version2.major) list.sorted shouldBe list - else list.sorted shouldBe list.reverse + if (version1.major < version2.major) list.sorted shouldBe list + else list.sorted shouldBe list.reverse + } } }