Skip to content

Commit

Permalink
Add the ability to extend the suite by providing additional project a…
Browse files Browse the repository at this point in the history
…s a parameter (#5045)

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

* Add documentation

---------

Co-authored-by: Simon Dumas <[email protected]>
  • Loading branch information
imsdu and Simon Dumas authored Jun 28, 2024
1 parent 673a1b6 commit 4709db9
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 51 deletions.
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

0 comments on commit 4709db9

Please sign in to comment.