Skip to content

Commit

Permalink
Add the ability to define suites in the search plugin (BlueBrain#3759)
Browse files Browse the repository at this point in the history
* Add the ability to define suites in the search plugin

---------

Co-authored-by: Simon Dumas <[email protected]>
  • Loading branch information
imsdu and Simon Dumas authored Mar 24, 2023
1 parent c4b1969 commit c034eaf
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 135 deletions.
4 changes: 4 additions & 0 deletions delta/plugins/search/src/main/resources/search.conf
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@ plugins.search {
name = "Default global search view"
description = "An Elasticsearch view of configured resources for the global search."
}

suites {
# my-suite = [ "myorg/myproject", "myorg/myproject2" ]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,35 @@ import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.indexing.projectionI
import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.CompositeViewProjection.ElasticSearchProjection
import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.{CompositeView, CompositeViewSearchParams}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.client.ElasticSearchClient
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchRejection.WrappedElasticSearchClientError
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchRejection.{UnknownSuite, WrappedElasticSearchClientError}
import ch.epfl.bluebrain.nexus.delta.plugins.search.model._
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.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sdk.model.search.Pagination
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import io.circe.{Json, JsonObject}
import monix.bio.{IO, UIO}

trait Search {

/**
* Queries the underlying elasticsearch search indices that the ''caller'' has access to
* Queries all the underlying search indices that the ''caller'' has access to
*
* @param payload
* the query payload
*/
def query(payload: JsonObject, qp: Uri.Query)(implicit caller: Caller): IO[SearchRejection, Json]

/**
* Queries the underlying search indices for the provided suite that the ''caller'' has access to
*
* @param suite
* the suite where the search query has to be applied
* @param payload
* the query payload
*/
def query(suite: Label, payload: JsonObject, qp: Uri.Query)(implicit caller: Caller): IO[SearchRejection, Json]
}

object Search {
Expand All @@ -39,7 +50,8 @@ object Search {
compositeViews: CompositeViews,
aclCheck: AclCheck,
client: ElasticSearchClient,
prefix: String
prefix: String,
suites: SearchConfig.Suites
): Search = {

val listProjections: ListProjections = () =>
Expand All @@ -59,7 +71,7 @@ object Search {
} yield TargetProjection(esProjection, res.value, res.rev)
}
)
apply(listProjections, aclCheck, client, prefix)
apply(listProjections, aclCheck, client, prefix, suites)
}

/**
Expand All @@ -69,19 +81,37 @@ object Search {
listProjections: ListProjections,
aclCheck: AclCheck,
client: ElasticSearchClient,
prefix: String
prefix: String,
suites: SearchConfig.Suites
): Search =
new Search {
override def query(payload: JsonObject, qp: Uri.Query)(implicit caller: Caller): IO[SearchRejection, Json] = {

private def query(projectionPredicate: TargetProjection => Boolean, payload: JsonObject, qp: Uri.Query)(implicit
caller: Caller
) =
for {
allProjections <- listProjections()
allProjections <- listProjections().map(_.filter(projectionPredicate))
accessibleIndices <- aclCheck.mapFilter[TargetProjection, String](
allProjections,
p => ProjectAcl(p.view.project) -> p.projection.permission,
p => projectionIndex(p.projection, p.view.uuid, p.rev, prefix).value
)
results <- client.search(payload, accessibleIndices, qp)().mapError(WrappedElasticSearchClientError)
} yield results
}

override def query(payload: JsonObject, qp: Uri.Query)(implicit caller: Caller): IO[SearchRejection, Json] =
query(_ => true, payload, qp)

override def query(suite: Label, payload: JsonObject, qp: Uri.Query)(implicit
caller: Caller
): IO[SearchRejection, Json] =
IO.fromOption(
suites.get(suite),
UnknownSuite(suite)
).flatMap { projects =>
def predicate(p: TargetProjection): Boolean = projects.contains(p.view.project)
query(predicate(_), payload, qp)
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ class SearchPluginModule(priority: Int) extends ModuleDef {
make[SearchConfig].fromEffect { cfg => SearchConfig.load(cfg) }

make[Search].from {
(compositeViews: CompositeViews, aclCheck: AclCheck, esClient: ElasticSearchClient, config: CompositeViewsConfig) =>
Search(compositeViews, aclCheck, esClient, config.prefix)
(
compositeViews: CompositeViews,
aclCheck: AclCheck,
esClient: ElasticSearchClient,
compositeConfig: CompositeViewsConfig,
searchConfig: SearchConfig
) =>
Search(compositeViews, aclCheck, esClient, compositeConfig.prefix, searchConfig.suites)
}

make[SearchScopeInitialization].from {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling
import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives.{baseUriPrefix, emit}
import ch.epfl.bluebrain.nexus.delta.sdk.directives.{AuthDirectives, DeltaDirectives}
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
Expand All @@ -27,7 +26,8 @@ class SearchRoutes(
)(implicit baseUri: BaseUri, s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering)
extends AuthDirectives(identities, aclCheck)
with CirceUnmarshalling
with RdfMarshalling {
with RdfMarshalling
with DeltaDirectives {

import baseUri.prefixSegment

Expand All @@ -37,9 +37,17 @@ class SearchRoutes(
extractCaller { implicit caller =>
concat(
// Query the underlying aggregate elasticsearch view for global search
(pathPrefix("query") & post & pathEndOrSingleSlash) {
operationName(s"$prefixSegment/search/query") {
(extractQueryParams & entity(as[JsonObject])) { (qp, payload) =>
(pathPrefix("query") & post) {
(extractQueryParams & entity(as[JsonObject])) { (qp, payload) =>
concat(
pathEndOrSingleSlash {
emit(search.query(payload, qp))
},
(pathPrefix("suite") & label & pathEndOrSingleSlash) { suite =>
emit(search.query(suite, payload, qp))
}
)
pathEndOrSingleSlash {
emit(search.query(payload, qp))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,56 @@ package ch.epfl.bluebrain.nexus.delta.plugins.search.model
import cats.syntax.all._
import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.TemplateSparqlConstructQuery
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfig.IndexingConfig
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfigError.{InvalidJsonError, InvalidSparqlConstructQuery, LoadingFileError}
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfigError.{InvalidJsonError, InvalidSparqlConstructQuery, InvalidSuites, LoadingFileError}
import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue.ContextObject
import ch.epfl.bluebrain.nexus.delta.rdf.query.SparqlQuery.SparqlConstructQuery
import ch.epfl.bluebrain.nexus.delta.sdk.Defaults
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef}
import com.typesafe.config.Config
import io.circe.parser._
import io.circe.{Decoder, JsonObject}
import monix.bio.IO
import pureconfig.ConfigSource
import pureconfig.configurable.genericMapReader
import pureconfig.error.CannotConvert
import pureconfig.{ConfigReader, ConfigSource}

import java.nio.file.{Files, Path}
import scala.util.Try

final case class SearchConfig(
indexing: IndexingConfig,
fields: Option[JsonObject],
defaults: Defaults
defaults: Defaults,
suites: SearchConfig.Suites
)

object SearchConfig {

implicit val projectRefReader: ConfigReader[ProjectRef] = ConfigReader.fromString { value =>
value.split("/").toList match {
case orgStr :: projectStr :: Nil =>
(Label(orgStr), Label(projectStr))
.mapN(ProjectRef(_, _))
.leftMap(err => CannotConvert(value, classOf[ProjectRef].getSimpleName, err.getMessage))
case _ =>
Left(CannotConvert(value, classOf[ProjectRef].getSimpleName, "Wrong format"))
}
}

type Suites = Map[Label, Set[ProjectRef]]
implicit private val suitesMapReader: ConfigReader[Suites] =
genericMapReader(str => Label(str).leftMap(e => CannotConvert(str, classOf[Label].getSimpleName, e.getMessage)))

/**
* Converts a [[Config]] into an [[SearchConfig]]
*/
def load(config: Config): IO[SearchConfigError, SearchConfig] = {
val pluginConfig = config.getConfig("plugins.search")
def loadSuites = {
val suiteSource = ConfigSource.fromConfig(pluginConfig).at("suites")
IO.fromEither(suiteSource.load[Suites]).mapError(InvalidSuites)
}
for {
fields <- loadOption(pluginConfig, "fields", loadExternalConfig[JsonObject])
resourceTypes <- loadExternalConfig[Set[Iri]](pluginConfig.getString("indexing.resource-types"))
Expand All @@ -38,6 +61,7 @@ object SearchConfig {
query <- loadSparqlQuery(pluginConfig.getString("indexing.query"))
context <- loadOption(pluginConfig, "indexing.context", loadExternalConfig[JsonObject])
defaults <- loadDefaults(pluginConfig)
suites <- loadSuites
} yield SearchConfig(
IndexingConfig(
resourceTypes,
Expand All @@ -47,7 +71,8 @@ object SearchConfig {
context = ContextObject(context.getOrElse(JsonObject.empty))
),
fields,
defaults
defaults,
suites
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ch.epfl.bluebrain.nexus.delta.plugins.search.model

import ch.epfl.bluebrain.nexus.delta.sdk.error.SDKError
import pureconfig.error.ConfigReaderFailures

abstract class SearchConfigError(val reason: String) extends SDKError

Expand All @@ -15,4 +16,7 @@ object SearchConfigError {
final case class InvalidSparqlConstructQuery(path: String, details: String)
extends SearchConfigError(s"File at path '$path' does not contain a valid SPARQL construct query: '$details'.")

final case class InvalidSuites(failures: ConfigReaderFailures)
extends SearchConfigError(s"Configuration for search suites is invalid:\n${failures.prettyPrint()}")

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError
import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import io.circe.syntax._
import io.circe.{Encoder, JsonObject}

Expand All @@ -27,6 +28,11 @@ object SearchRejection {
final case class WrappedElasticSearchClientError(error: HttpClientError)
extends SearchRejection("Error while interacting with the underlying ElasticSearch index")

/**
* Signals a rejection caused when interacting with the elasticserch client
*/
final case class UnknownSuite(value: Label) extends SearchRejection(s"The suite '$value' can't be found.")

implicit private[plugins] val searchViewRejectionEncoder: Encoder.AsObject[SearchRejection] =
Encoder.AsObject.instance { r =>
val tpe = ClassUtils.simpleName(r)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.client.IndexLabel.Ind
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.permissions
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.{Fixtures, ScalaTestElasticSearchClientSetup}
import ch.epfl.bluebrain.nexus.delta.plugins.search.Search.{ListProjections, TargetProjection}
import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchRejection.UnknownSuite
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue.ContextObject
import ch.epfl.bluebrain.nexus.delta.rdf.query.SparqlQuery.SparqlConstructQuery
Expand Down Expand Up @@ -118,10 +119,17 @@ class SearchSpec
projectionProj2
)

private val tpe1 = nxv + "Type1"

private val listViews: ListProjections = () => UIO.pure(projections)

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

private val tpe1 = nxv + "Type1"

private def createDocuments(proj: TargetProjection): Seq[Json] =
(0 until 3).map { idx =>
val resource =
Expand All @@ -145,7 +153,14 @@ class SearchSpec
private val prefix = "prefix"

"Search" should {
lazy val search = Search(listViews, aclCheck, esClient, prefix)
lazy val search = Search(listViews, aclCheck, esClient, prefix, allSuites)

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[ElasticSearchBulk]) { (bulk, p) =>
Expand All @@ -159,15 +174,38 @@ class SearchSpec
esClient.bulk(bulkSeq, Refresh.WaitFor).accepted
}

"search all indices" in {
val results = search.query(jobj"""{"size": 100}""", Query.Empty)(bob).accepted
extractSources(results).toSet shouldEqual projections.flatMap(createDocuments).toSet
"search all indices accordingly to Bob's full access" in {
val results = search.query(matchAll, noParameters)(bob).accepted
extractSources(results).toSet shouldEqual allDocuments
}

"search only indices the user has access to" in {
val results = search.query(jobj"""{"size": 100}""", Query.Empty)(alice).accepted
extractSources(results).toSet shouldEqual createDocuments(projectionProj1).toSet
"search only the project 1 index accordingly to Alice's restricted access" in {
val results = search.query(matchAll, noParameters)(alice).accepted
extractSources(results).toSet shouldEqual project1Documents
}

"search within an unknown suite" in {
search.query(Label.unsafe("xxx"), 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
extractSources(results).toSet shouldEqual expected
}
}

List(
(allSuite, project1Documents),
(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
extractSources(results).toSet shouldEqual expected
}
}
}
}
Loading

0 comments on commit c034eaf

Please sign in to comment.