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/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 + } } } 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.