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

Add the ability to extend the suite by providing additional project as a parameter #5045

Merged
merged 3 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -15,7 +15,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress.{Project => ProjectAcl}
import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef}
import io.circe.{Json, JsonObject}

trait Search {
Expand All @@ -36,7 +36,9 @@ trait Search {
* @param payload
* the query payload
*/
def query(suite: Label, payload: JsonObject, qp: Uri.Query)(implicit caller: Caller): IO[Json]
def query(suite: Label, additionalProjects: Set[ProjectRef], payload: JsonObject, qp: Uri.Query)(implicit
caller: Caller
): IO[Json]
}

object Search {
Expand Down Expand Up @@ -106,11 +108,12 @@ object Search {
override def query(payload: JsonObject, qp: Uri.Query)(implicit caller: Caller): IO[Json] =
query(_ => true, payload, qp)

override def query(suite: Label, payload: JsonObject, qp: Uri.Query)(implicit
override def query(suite: Label, additionalProjects: Set[ProjectRef], payload: JsonObject, qp: Uri.Query)(implicit
caller: Caller
): IO[Json] = {
IO.fromOption(suites.get(suite))(UnknownSuite(suite)).flatMap { projects =>
def predicate(p: TargetProjection): Boolean = projects.contains(p.view.project)
IO.fromOption(suites.get(suite))(UnknownSuite(suite)).flatMap { suiteProjects =>
val allProjects = suiteProjects ++ additionalProjects
def predicate(p: TargetProjection): Boolean = allProjects.contains(p.view.project)
query(predicate(_), payload, qp)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.directives.{AuthDirectives, DeltaDirect
import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities
import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfMarshalling
import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri
import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef
import io.circe.{Json, JsonObject}
import kamon.instrumentation.akka.http.TracingDirectives.operationName

Expand All @@ -32,6 +33,10 @@ class SearchRoutes(

import baseUri.prefixSegment

private val addProjectParam = "addProject"

private def additionalProjects = parameter(addProjectParam.as[ProjectRef].*)

def routes: Route =
baseUriPrefix(baseUri.prefix) {
pathPrefix("search") {
Expand All @@ -44,8 +49,14 @@ class SearchRoutes(
pathEndOrSingleSlash {
emit(search.query(payload, qp).attemptNarrow[SearchRejection])
},
(pathPrefix("suite") & label & pathEndOrSingleSlash) { suite =>
emit(search.query(suite, payload, qp).attemptNarrow[SearchRejection])
(pathPrefix("suite") & label & additionalProjects & pathEndOrSingleSlash) {
(suite, additionalProjects) =>
val filteredQp = qp.filterNot { case (key, _) => key == addProjectParam }
emit(
search
.query(suite, additionalProjects.toSet, payload, filteredQp)
.attemptNarrow[SearchRejection]
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ch.epfl.bluebrain.nexus.delta.plugins.search
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.server.Route
import cats.effect.IO
import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchRejection.UnknownSuite
import ch.epfl.bluebrain.nexus.delta.plugins.search.SuiteMatchers._
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck
Expand All @@ -24,10 +25,11 @@ class SearchRoutesSpec extends BaseRouteSpec {
IO.raiseWhen(payload.isEmpty)(unknownSuite).as(payload.asJson)
}

override def query(suite: Label, payload: JsonObject, qp: Uri.Query)(implicit
override def query(suite: Label, additionalProjects: Set[ProjectRef], payload: JsonObject, qp: Uri.Query)(implicit
caller: Caller
): IO[Json] =
IO.raiseWhen(payload.isEmpty)(unknownSuite).as(Json.obj(suite.value -> payload.asJson))
IO.raiseWhen(payload.isEmpty)(unknownSuite)
.as(Json.obj(suite.value -> payload.asJson, "addProjects" -> additionalProjects.asJson))
}

private val fields = Json.obj("fields" := true)
Expand Down Expand Up @@ -68,11 +70,16 @@ class SearchRoutesSpec extends BaseRouteSpec {
"fetch a result related to a search in a suite" in {
val searchSuiteName = "public"
val payload = Json.obj("searchSuite" := true)

Post(s"/v1/search/query/suite/$searchSuiteName", payload.toEntity) ~> routes ~> check {
val expectedResponse = Json.obj(searchSuiteName -> payload)
val project1 = ProjectRef.unsafe("org", "proj")
val project2 = ProjectRef.unsafe("org", "proj2")
val projects = Set(project1, project2)
val queryParams =
s"?addProject=${UrlUtils.encode(project1.toString)}&addProject=${UrlUtils.encode(project2.toString)}"

Post(s"/v1/search/query/suite/$searchSuiteName$queryParams", payload.toEntity) ~> routes ~> check {
val expectedResponse = Json.obj(searchSuiteName -> payload, "addProjects" -> projects.asJson)
status shouldEqual StatusCodes.OK
response.asJson shouldEqual expectedResponse
response.asJson should equalIgnoreArrayOrder(expectedResponse)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ import ch.epfl.bluebrain.nexus.delta.rdf.syntax._
import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck
import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress
import ch.epfl.bluebrain.nexus.delta.sdk.generators.{ProjectGen, ResourceGen}
import ch.epfl.bluebrain.nexus.delta.sdk.generators.ResourceGen
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, Tags}
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission
import ch.epfl.bluebrain.nexus.delta.sdk.views.IndexingRev
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Group, User}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{IriFilter, Label}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{IriFilter, Label, ProjectRef}
import ch.epfl.bluebrain.nexus.testkit.elasticsearch.ElasticSearchDocker
import ch.epfl.bluebrain.nexus.testkit.scalatest.ce.CatsEffectSpec
import io.circe.{Json, JsonObject}
Expand Down Expand Up @@ -61,12 +61,12 @@ class SearchSpec
implicit private val alice: Caller = Caller(User("Alice", realm), Set(User("Alice", realm), Group("users", realm)))
private val bob: Caller = Caller(User("Bob", realm), Set(User("Bob", realm), Group("users", realm)))

private val project1 = ProjectGen.project("org", "proj")
private val project2 = ProjectGen.project("org2", "proj2")
private val project1 = ProjectRef.unsafe("org", "proj")
private val project2 = ProjectRef.unsafe("org2", "proj2")
private val queryPermission = Permission.unsafe("views/query")

private val aclCheck = AclSimpleCheck(
(alice.subject, AclAddress.Project(project1.ref), Set(queryPermission)),
(alice.subject, AclAddress.Project(project1), Set(queryPermission)),
(bob.subject, AclAddress.Root, Set(queryPermission))
).accepted

Expand All @@ -91,7 +91,7 @@ class SearchSpec

private val compViewProj1 = CompositeView(
nxv + "searchView",
project1.ref,
project1,
NonEmptyList.of(
ProjectSource(nxv + "searchSource", UUID.randomUUID(), IriFilter.None, IriFilter.None, None, false)
),
Expand All @@ -102,7 +102,7 @@ class SearchSpec
Json.obj(),
Instant.EPOCH
)
private val compViewProj2 = compViewProj1.copy(project = project2.ref, uuid = UUID.randomUUID())
private val compViewProj2 = compViewProj1.copy(project = project2, uuid = UUID.randomUUID())
private val projectionProj1 = TargetProjection(esProjection, compViewProj1)
private val projectionProj2 = TargetProjection(esProjection, compViewProj2)

Expand All @@ -111,10 +111,12 @@ class SearchSpec
private val listViews: ListProjections = () => IO.pure(projections)

private val allSuite = Label.unsafe("allSuite")
private val proj1Suite = Label.unsafe("proj1Suite")
private val proj2Suite = Label.unsafe("proj2Suite")
private val allSuites = Map(
allSuite -> Set(project1.ref, project2.ref),
proj2Suite -> Set(project2.ref)
allSuite -> Set(project1, project2),
proj1Suite -> Set(project1),
proj2Suite -> Set(project2)
)

private val tpe1 = nxv + "Type1"
Expand All @@ -139,6 +141,23 @@ class SearchSpec
.rightValue
}

val project1Documents = createDocuments(projectionProj1).toSet
val project2Documents = createDocuments(projectionProj2).toSet
val allDocuments = project1Documents ++ project2Documents

override def beforeAll(): Unit = {
super.beforeAll()
val bulkSeq = projections.foldLeft(Seq.empty[ElasticSearchAction]) { (bulk, p) =>
val index = projectionIndex(p.projection, p.view.uuid, prefix)
esClient.createIndex(index, Some(mappings), None).accepted
val newBulk = createDocuments(p).zipWithIndex.map { case (json, idx) =>
ElasticSearchAction.Index(index, idx.toString, json)
}
bulk ++ newBulk
}
esClient.bulk(bulkSeq, Refresh.WaitFor).void.accepted
}

private val prefix = "prefix"

"Search" should {
Expand All @@ -147,22 +166,6 @@ class SearchSpec
val matchAll = jobj"""{"size": 100}"""
val noParameters = Query.Empty

val project1Documents = createDocuments(projectionProj1).toSet
val project2Documents = createDocuments(projectionProj2).toSet
val allDocuments = project1Documents ++ project2Documents

"index documents" in {
val bulkSeq = projections.foldLeft(Seq.empty[ElasticSearchAction]) { (bulk, p) =>
val index = projectionIndex(p.projection, p.view.uuid, prefix)
esClient.createIndex(index, Some(mappings), None).accepted
val newBulk = createDocuments(p).zipWithIndex.map { case (json, idx) =>
ElasticSearchAction.Index(index, idx.toString, json)
}
bulk ++ newBulk
}
esClient.bulk(bulkSeq, Refresh.WaitFor).accepted
}

"search all indices accordingly to Bob's full access" in {
val results = search.query(matchAll, noParameters)(bob).accepted
extractSources(results).toSet shouldEqual allDocuments
Expand All @@ -174,15 +177,15 @@ class SearchSpec
}

"search within an unknown suite" in {
search.query(Label.unsafe("xxx"), matchAll, noParameters)(bob).rejectedWith[UnknownSuite]
search.query(Label.unsafe("xxx"), Set.empty, matchAll, noParameters)(bob).rejectedWith[UnknownSuite]
}

List(
(allSuite, allDocuments),
(proj2Suite, project2Documents)
).foreach { case (suite, expected) =>
s"search within suite $suite accordingly to Bob's full access" in {
val results = search.query(suite, matchAll, noParameters)(bob).accepted
val results = search.query(suite, Set.empty, matchAll, noParameters)(bob).accepted
extractSources(results).toSet shouldEqual expected
}
}
Expand All @@ -192,9 +195,24 @@ class SearchSpec
(proj2Suite, Set.empty)
).foreach { case (suite, expected) =>
s"search within suite $suite accordingly to Alice's restricted access" in {
val results = search.query(suite, matchAll, noParameters)(alice).accepted
val results = search.query(suite, Set.empty, matchAll, noParameters)(alice).accepted
extractSources(results).toSet shouldEqual expected
}
}

"Search on proj2Suite and add project1 as an extra project accordingly to Bob's full access" in {
val results = search.query(proj2Suite, Set(project1), matchAll, noParameters)(bob).accepted
extractSources(results).toSet shouldEqual allDocuments
}

"Search on proj1Suite and add project2 as an extra project accordingly to Alice's restricted access" in {
val results = search.query(proj1Suite, Set(project2), matchAll, noParameters)(alice).accepted
extractSources(results).toSet shouldEqual project1Documents
}

"Search on proj2Suite and add project1 as an extra project accordingly to Alice's restricted access" in {
val results = search.query(proj2Suite, Set(project1), matchAll, noParameters)(alice).accepted
extractSources(results).toSet shouldEqual project1Documents
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider.A
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, ProjectContext}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import io.circe.Json
import io.circe.parser.parse
Expand All @@ -21,7 +21,7 @@ import io.circe.parser.parse
trait QueryParamsUnmarshalling {

/**
* Unmarsaller to transform a String to Iri
* Unmarshaller to transform a String to Iri
*/
implicit val iriFromStringUnmarshaller: FromStringUnmarshaller[Iri] =
Unmarshaller.strict[String, Iri] { string =>
Expand All @@ -32,7 +32,7 @@ trait QueryParamsUnmarshalling {
}

/**
* Unmarsaller to transform a String to an IriBase
* Unmarshaller to transform a String to an IriBase
*/
val iriBaseFromStringUnmarshallerNoExpansion: FromStringUnmarshaller[IriBase] =
iriFromStringUnmarshaller.map(IriBase)
Expand All @@ -44,7 +44,7 @@ trait QueryParamsUnmarshalling {
expandIriFromStringUnmarshaller(useVocab = true).map(IriVocab)

/**
* Unmarsaller to transform a String to an IriBase
* Unmarshaller to transform a String to an IriBase
*/
implicit def iriBaseFromStringUnmarshaller(implicit pc: ProjectContext): FromStringUnmarshaller[IriBase] =
expandIriFromStringUnmarshaller(useVocab = false).map(IriBase)
Expand All @@ -71,7 +71,7 @@ trait QueryParamsUnmarshalling {
)

/**
* Unmarsaller to transform a String to Label
* Unmarshaller to transform a String to Label
*/
implicit def labelFromStringUnmarshaller: FromStringUnmarshaller[Label] =
Unmarshaller.strict[String, Label] { string =>
Expand All @@ -81,8 +81,16 @@ trait QueryParamsUnmarshalling {
}
}

implicit def projectRefFromStringUnmarshaller: FromStringUnmarshaller[ProjectRef] =
Unmarshaller.strict[String, ProjectRef] { string =>
ProjectRef.parse(string) match {
case Right(iri) => iri
case Left(err) => throw new IllegalArgumentException(err)
}
}

/**
* Unmarsaller to transform a String to TagLabel
* Unmarshaller to transform a String to TagLabel
*/
implicit def tagLabelFromStringUnmarshaller: FromStringUnmarshaller[UserTag] =
Unmarshaller.strict[String, UserTag] { string =>
Expand All @@ -109,7 +117,7 @@ trait QueryParamsUnmarshalling {
}

/**
* Unmarsaller to transform an Iri to a Subject
* Unmarshaller to transform an Iri to a Subject
*/
implicit def subjectFromIriUnmarshaller(implicit base: BaseUri): Unmarshaller[Iri, Subject] =
Unmarshaller.strict[Iri, Subject] { iri =>
Expand All @@ -120,13 +128,13 @@ trait QueryParamsUnmarshalling {
}

/**
* Unmarsaller to transform a String to a Subject
* Unmarshaller to transform a String to a Subject
*/
implicit def subjectFromStringUnmarshaller(implicit base: BaseUri): FromStringUnmarshaller[Subject] =
iriFromStringUnmarshaller.andThen(subjectFromIriUnmarshaller)

/**
* Unmarsaller to transform a String to an IdSegment
* Unmarshaller to transform a String to an IdSegment
*/
implicit val idSegmentFromStringUnmarshaller: FromStringUnmarshaller[IdSegment] =
Unmarshaller.strict[String, IdSegment](IdSegment.apply)
Expand Down
4 changes: 3 additions & 1 deletion docs/src/main/paradox/docs/delta/api/search-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ Nexus Delta allows to configure multiple search suites under @link:[`plugins.sea
When querying using a suite, the query is only performed on the underlying Elasticsearch indices of the projects in the suite.

```
POST /v1/search/query/suite/{suiteName}
POST /v1/search/query/suite/{suiteName}?addProject={project}
{payload}
```
... where:

* `{suiteName}` is the name of the suite
* `{project}`: Project - can be used to extend the scope of the suite by providing other projects under the format `org/project`. This parameter can appear
multiple times, expanding further the scope of the search.
* `{payload}` is a @link:[Elasticsearch query](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html){ open=new }
and the response is forwarded from the underlying Elasticsearch indices.

Expand Down