Repository: BlueBrain/nexus (scala)

Issue:
Add the ability to extend the scope when searching the suite
**Motivation**
Within virtual labs, there is the need to be able to query both the sbo suite and the current project of the virtual lab the user is working on.
So we need to allow to extend the scope of the suite depending on the client request

**Acceptance criteria**
1. When calling the search suite endpoint, the call can provide an additional project to extend the initial scope of the suite
2. When the project does not exist, the parameter is ignored and the search is done on the initial scope
3. When the user has no access to this additional project, and the search is done on the initial scope
4. Repeating a project already present in the suite has no effect

**Implementation notes**
Note that 2. and 3. should be handled by default, the change should be only about merging the projects of the suite and this additional project and pass it along

**API changes**
POST /v1/search/query/suite/{suiteName}?extend={org/proj}

Pull request description:
Fixes #5042

Patch:
diff --git a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/Search.scala b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/Search.scala
index 08776da7a6..04dc529ada 100644
--- a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/Search.scala
+++ b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/Search.scala
@@ -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 {
@@ -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 {
@@ -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)
         }
       }
diff --git a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutes.scala b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutes.scala
index 33b7887bab..e749a8acef 100644
--- a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutes.scala
+++ b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutes.scala
@@ -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
 
@@ -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") {
@@ -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]
+                      )
                   }
                 )
               }
diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala
index df76cccfef..8d22c92f2d 100644
--- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala
+++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala
@@ -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
@@ -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 =>
@@ -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)
@@ -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)
@@ -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 =>
@@ -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 =>
@@ -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 =>
@@ -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)
diff --git a/docs/src/main/paradox/docs/delta/api/search-api.md b/docs/src/main/paradox/docs/delta/api/search-api.md
index acdb953e52..4b89be9f41 100644
--- a/docs/src/main/paradox/docs/delta/api/search-api.md
+++ b/docs/src/main/paradox/docs/delta/api/search-api.md
@@ -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.
 


Test patch:
diff --git a/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutesSpec.scala b/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutesSpec.scala
index dcde19c769..ef0d83bef3 100644
--- a/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutesSpec.scala
+++ b/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchRoutesSpec.scala
@@ -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
@@ -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)
@@ -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)
       }
     }
 
diff --git a/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchSpec.scala b/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchSpec.scala
index 388a85cea8..44fc7afb47 100644
--- a/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchSpec.scala
+++ b/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/SearchSpec.scala
@@ -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}
@@ -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
 
@@ -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)
     ),
@@ -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)
 
@@ -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"
@@ -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 {
@@ -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
@@ -174,7 +177,7 @@ 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(
@@ -182,7 +185,7 @@ class SearchSpec
       (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
       }
     }
@@ -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
+    }
   }
 }