diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/MultiFetchRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/MultiFetchRoutes.scala new file mode 100644 index 0000000000..9af9590cb5 --- /dev/null +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/MultiFetchRoutes.scala @@ -0,0 +1,48 @@ +package ch.epfl.bluebrain.nexus.delta.routes + +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +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._ +import ch.epfl.bluebrain.nexus.delta.sdk.directives.UriDirectives.baseUriPrefix +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.sdk.multifetch.MultiFetch +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchRequest +import monix.execution.Scheduler + +/** + * Route allowing to fetch multiple resources in a single request + */ +class MultiFetchRoutes( + identities: Identities, + aclCheck: AclCheck, + multiFetch: MultiFetch +)(implicit + baseUri: BaseUri, + cr: RemoteContextResolution, + ordering: JsonKeyOrdering, + s: Scheduler +) extends AuthDirectives(identities, aclCheck) + with CirceUnmarshalling + with RdfMarshalling { + + def routes: Route = + baseUriPrefix(baseUri.prefix) { + pathPrefix("multi-fetch") { + pathPrefix("resources") { + extractCaller { implicit caller => + (get & entity(as[MultiFetchRequest])) { request => + emit(multiFetch(request).flatMap(_.asJson)) + } + } + } + } + } + +} diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala index 8a7767d41e..7c4f0915af 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala @@ -165,6 +165,7 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class include(ResolversModule) include(SchemasModule) include(ResourcesModule) + include(MultiFetchModule) include(IdentitiesModule) include(VersionModule) include(QuotasModule) diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/MultiFetchModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/MultiFetchModule.scala new file mode 100644 index 0000000000..dae425ae9a --- /dev/null +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/MultiFetchModule.scala @@ -0,0 +1,47 @@ +package ch.epfl.bluebrain.nexus.delta.wiring + +import ch.epfl.bluebrain.nexus.delta.Main.pluginsMaxPriority +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.routes.MultiFetchRoutes +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities +import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.MultiFetch +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchRequest +import ch.epfl.bluebrain.nexus.delta.sdk.{PriorityRoute, ResourceShifts} +import distage.ModuleDef +import izumi.distage.model.definition.Id +import monix.execution.Scheduler + +object MultiFetchModule extends ModuleDef { + + make[MultiFetch].from { + ( + aclCheck: AclCheck, + shifts: ResourceShifts + ) => + MultiFetch( + aclCheck, + (input: MultiFetchRequest.Input) => shifts.fetch(input.id, input.project) + ) + } + + make[MultiFetchRoutes].from { + ( + identities: Identities, + aclCheck: AclCheck, + multiFetch: MultiFetch, + baseUri: BaseUri, + rcr: RemoteContextResolution @Id("aggregate"), + jko: JsonKeyOrdering, + sc: Scheduler + ) => + new MultiFetchRoutes(identities, aclCheck, multiFetch)(baseUri, rcr, jko, sc) + } + + many[PriorityRoute].add { (route: MultiFetchRoutes) => + PriorityRoute(pluginsMaxPriority + 13, route.routes, requiresStrictEntity = true) + } + +} diff --git a/delta/app/src/test/resources/multi-fetch/all-unauthorized.json b/delta/app/src/test/resources/multi-fetch/all-unauthorized.json new file mode 100644 index 0000000000..e998afff24 --- /dev/null +++ b/delta/app/src/test/resources/multi-fetch/all-unauthorized.json @@ -0,0 +1,32 @@ +{ + "format": "compacted", + "resources": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/success", + "error": { + "@context": "https://bluebrain.github.io/nexus/contexts/error.json", + "@type": "AuthorizationFailed", + "reason": "The supplied authentication is not authorized to access this resource." + }, + "project": "org/proj1" + }, + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/not-found", + "error": { + "@context": "https://bluebrain.github.io/nexus/contexts/error.json", + "@type": "AuthorizationFailed", + "reason": "The supplied authentication is not authorized to access this resource." + }, + "project": "org/proj1" + }, + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/unauthorized", + "error": { + "@context": "https://bluebrain.github.io/nexus/contexts/error.json", + "@type": "AuthorizationFailed", + "reason": "The supplied authentication is not authorized to access this resource." + }, + "project": "org/proj2" + } + ] +} \ No newline at end of file diff --git a/delta/app/src/test/resources/multi-fetch/compacted-response.json b/delta/app/src/test/resources/multi-fetch/compacted-response.json new file mode 100644 index 0000000000..e7dc0a0333 --- /dev/null +++ b/delta/app/src/test/resources/multi-fetch/compacted-response.json @@ -0,0 +1,52 @@ +{ + "format": "compacted", + "resources": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/success", + "project": "org/proj1", + "value": { + "@context": [ + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + }, + "https://bluebrain.github.io/nexus/contexts/metadata.json" + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/success", + "@type": "Custom", + "bool": false, + "name": "Alex", + "number": 24, + "_constrainedBy": "https://bluebrain.github.io/nexus/schemas/unconstrained.json", + "_createdAt": "1970-01-01T00:00:00Z", + "_createdBy": "http://localhost/v1/anonymous", + "_deprecated": false, + "_incoming": "http://localhost/v1/resources/org/proj1/https:%2F%2Fbluebrain.github.io%2Fnexus%2Fschemas%2Funconstrained.json/success/incoming", + "_outgoing": "http://localhost/v1/resources/org/proj1/https:%2F%2Fbluebrain.github.io%2Fnexus%2Fschemas%2Funconstrained.json/success/outgoing", + "_project": "http://localhost/v1/projects/org/proj1", + "_rev": 1, + "_schemaProject": "http://localhost/v1/projects/org/proj1", + "_self": "http://localhost/v1/resources/org/proj1/https:%2F%2Fbluebrain.github.io%2Fnexus%2Fschemas%2Funconstrained.json/success", + "_updatedAt": "1970-01-01T00:00:00Z", + "_updatedBy": "http://localhost/v1/anonymous" + } + }, + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/not-found", + "error": { + "@context": "https://bluebrain.github.io/nexus/contexts/error.json", + "@type": "NotFound", + "reason": "The resource 'https://bluebrain.github.io/nexus/vocabulary/not-found' was not found in project 'org/proj1'." + }, + "project": "org/proj1" + }, + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/unauthorized", + "error": { + "@context": "https://bluebrain.github.io/nexus/contexts/error.json", + "@type": "AuthorizationFailed", + "reason": "The supplied authentication is not authorized to access this resource." + }, + "project": "org/proj2" + } + ] +} \ No newline at end of file diff --git a/delta/app/src/test/resources/multi-fetch/source-response.json b/delta/app/src/test/resources/multi-fetch/source-response.json new file mode 100644 index 0000000000..ee5bace34d --- /dev/null +++ b/delta/app/src/test/resources/multi-fetch/source-response.json @@ -0,0 +1,35 @@ +{ + "format": "source", + "resources": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/success", + "project": "org/proj1", + "value": { + "@context": { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + }, + "@id": "https://bluebrain.github.io/nexus/vocabulary/success", + "@type": "Custom", + "bool": false, + "name": "Alex", + "number": 24 + } + }, + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/not-found", + "error": { + "@type": "NotFound", + "reason": "The resource 'https://bluebrain.github.io/nexus/vocabulary/not-found' was not found in project 'org/proj1'." + }, + "project": "org/proj1" + }, + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/unauthorized", + "error": { + "@type": "AuthorizationFailed", + "reason": "The supplied authentication is not authorized to access this resource." + }, + "project": "org/proj2" + } + ] +} \ No newline at end of file diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/MultiFetchRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/MultiFetchRoutesSpec.scala new file mode 100644 index 0000000000..7e64b16f90 --- /dev/null +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/MultiFetchRoutesSpec.scala @@ -0,0 +1,100 @@ +package ch.epfl.bluebrain.nexus.delta.routes + +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.headers.OAuth2BearerToken +import akka.http.scaladsl.server.Route +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck +import ch.epfl.bluebrain.nexus.delta.sdk.generators.ResourceGen +import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesDummy +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.MultiFetch +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchRequest +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions +import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef +import ch.epfl.bluebrain.nexus.delta.sourcing.model.ResourceRef.Latest +import monix.bio.UIO + +class MultiFetchRoutesSpec extends BaseRouteSpec { + + implicit private val caller: Caller = + Caller(alice, Set(alice, Anonymous, Authenticated(realm), Group("group", realm))) + + private val asAlice = addCredentials(OAuth2BearerToken("alice")) + + private val identities = IdentitiesDummy(caller) + + private val project1 = ProjectRef.unsafe("org", "proj1") + private val project2 = ProjectRef.unsafe("org", "proj2") + + private val permissions = Set(Permissions.resources.read) + private val aclCheck = AclSimpleCheck((alice, project1, permissions)).runSyncUnsafe() + + private val successId = nxv + "success" + private val successContent = + ResourceGen.jsonLdContent(successId, project1, jsonContentOf("resources/resource.json", "id" -> successId)) + + private val notFoundId = nxv + "not-found" + private val unauthorizedId = nxv + "unauthorized" + + private def fetchResource = + (input: MultiFetchRequest.Input) => { + input match { + case MultiFetchRequest.Input(Latest(`successId`), `project1`) => + UIO.some(successContent) + case _ => UIO.none + } + } + + private val multiFetch = MultiFetch( + aclCheck, + fetchResource + ) + + private val routes = Route.seal( + new MultiFetchRoutes(identities, aclCheck, multiFetch).routes + ) + + "The Multi fetch route" should { + + val endpoint = "/v1/multi-fetch/resources" + + def request(format: ResourceRepresentation) = + json""" + { + "format": "$format", + "resources": [ + { "id": "$successId", "project": "$project1" }, + { "id": "$notFoundId", "project": "$project1" }, + { "id": "$unauthorizedId", "project": "$project2" } + ] + }""" + + "return unauthorised results for a user with no access" in { + val entity = request(ResourceRepresentation.CompactedJsonLd).toEntity + Get(endpoint, entity) ~> routes ~> check { + status shouldEqual StatusCodes.OK + response.asJson shouldEqual jsonContentOf("multi-fetch/all-unauthorized.json") + } + } + + "return expected results as compacted json-ld for a user with limited access" in { + val entity = request(ResourceRepresentation.CompactedJsonLd).toEntity + Get(endpoint, entity) ~> asAlice ~> routes ~> check { + status shouldEqual StatusCodes.OK + response.asJson shouldEqual jsonContentOf("multi-fetch/compacted-response.json") + } + } + + "return expected results as original payloads for a user with limited access" in { + val entity = request(ResourceRepresentation.SourceJson).toEntity + Get(endpoint, entity) ~> asAlice ~> routes ~> check { + status shouldEqual StatusCodes.OK + response.asJson shouldEqual jsonContentOf("multi-fetch/source-response.json") + } + } + } +} diff --git a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownload.scala b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownload.scala index eac82d552f..30f14af772 100644 --- a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownload.scala +++ b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownload.scala @@ -5,7 +5,7 @@ import akka.util.ByteString import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveReference.{FileReference, FileSelfReference, ResourceReference} import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveRejection._ -import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveResourceRepresentation._ +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation._ import ch.epfl.bluebrain.nexus.delta.plugins.archive.model._ import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection import ch.epfl.bluebrain.nexus.delta.rdf.RdfError @@ -21,7 +21,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.directives.Response.Complete import ch.epfl.bluebrain.nexus.delta.sdk.error.SDKError import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdContent -import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri +import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceRepresentation} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.resources import ch.epfl.bluebrain.nexus.delta.sdk.stream.StreamConverter import ch.epfl.bluebrain.nexus.delta.sdk.{AkkaSource, JsonLdValue} @@ -249,7 +249,7 @@ object ArchiveDownload { private def valueToByteString[A]( value: JsonLdContent[A, _], - repr: ArchiveResourceRepresentation + repr: ResourceRepresentation ): IO[RdfError, ByteString] = { implicit val encoder: JsonLdEncoder[A] = value.encoder repr match { diff --git a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveReference.scala b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveReference.scala index e4d41d0f8c..d26ffc7a44 100644 --- a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveReference.scala +++ b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveReference.scala @@ -3,7 +3,7 @@ package ch.epfl.bluebrain.nexus.delta.plugins.archive.model import akka.http.scaladsl.model.Uri import cats.Order import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils -import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveResourceRepresentation.{CompactedJsonLd, SourceJson} +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation.{CompactedJsonLd, SourceJson} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.AbsolutePath import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv @@ -12,6 +12,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.JsonLdDecoderError.ParsingFailure import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.configuration.semiauto._ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.{Configuration, JsonLdDecoder} +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation import ch.epfl.bluebrain.nexus.delta.sourcing.model.ResourceRef.{Latest, Revision, Tag} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef} @@ -68,10 +69,10 @@ object ArchiveReference { ref: ResourceRef, project: Option[ProjectRef], path: Option[AbsolutePath], - representation: Option[ArchiveResourceRepresentation] + representation: Option[ResourceRepresentation] ) extends FullArchiveReference { - def representationOrDefault: ArchiveResourceRepresentation = representation.getOrElse(CompactedJsonLd) + def representationOrDefault: ResourceRepresentation = representation.getOrElse(CompactedJsonLd) def defaultFileName = s"${UrlUtils.encode(ref.original.toString)}${representationOrDefault.extension}" } @@ -132,7 +133,7 @@ object ArchiveReference { rev: Option[Int], path: Option[AbsolutePath], originalSource: Option[Boolean], - format: Option[ArchiveResourceRepresentation] + format: Option[ResourceRepresentation] ) extends ReferenceInput final private case class FileInput( @@ -165,11 +166,11 @@ object ArchiveReference { implicit val cfg: Configuration = Configuration.default.copy(context = ctx) deriveConfigJsonLdDecoder[ReferenceInput].flatMap { - case ResourceInput(_, _, Some(_: UserTag), Some(_: Int), _, _, _) => + case ResourceInput(_, _, Some(_: UserTag), Some(_: Int), _, _, _) => Left(ParsingFailure("An archive resource reference cannot use both 'rev' and 'tag' fields.")) - case ResourceInput(_, _, _, _, _, Some(_: Boolean), Some(_: ArchiveResourceRepresentation)) => + case ResourceInput(_, _, _, _, _, Some(_: Boolean), Some(_: ResourceRepresentation)) => Left(ParsingFailure("An archive resource reference cannot use both 'originalSource' and 'format' fields.")) - case ResourceInput(resourceId, project, tag, rev, path, originalSource, format) => + case ResourceInput(resourceId, project, tag, rev, path, originalSource, format) => val ref = refOf(resourceId, tag, rev) val repr = (originalSource, format) match { case (_, Some(repr)) => Some(repr) @@ -178,12 +179,12 @@ object ArchiveReference { case _ => None } Right(ResourceReference(ref, project, path, repr)) - case FileInput(_, _, Some(_: UserTag), Some(_: Int), _) => + case FileInput(_, _, Some(_: UserTag), Some(_: Int), _) => Left(ParsingFailure("An archive file reference cannot use both 'rev' and 'tag' fields.")) - case FileInput(resourceId, project, tag, rev, path) => + case FileInput(resourceId, project, tag, rev, path) => val ref = refOf(resourceId, tag, rev) Right(FileReference(ref, project, path)) - case FileSelfInput(value, path) => + case FileSelfInput(value, path) => Right(FileSelfReference(value, path)) } } diff --git a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveResourceRepresentation.scala b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveResourceRepresentation.scala deleted file mode 100644 index d140c59e26..0000000000 --- a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveResourceRepresentation.scala +++ /dev/null @@ -1,92 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.plugins.archive.model - -import cats.Order -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.JsonLdDecoder -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.JsonLdDecoderError.ParsingFailure -import io.circe.Encoder - -/** - * Enumeration of representations for resource references. - */ -sealed trait ArchiveResourceRepresentation extends Product with Serializable { - - /** - * Default extension for the format - */ - def extension: String - -} - -object ArchiveResourceRepresentation { - - /** - * Source representation of a resource. - */ - final case object SourceJson extends ArchiveResourceRepresentation { - override def extension: String = ".json" - - override val toString: String = "source" - } - - /** - * Compacted JsonLD representation of a resource. - */ - final case object CompactedJsonLd extends ArchiveResourceRepresentation { - override def extension: String = ".json" - - override val toString: String = "compacted" - } - - /** - * Expanded JsonLD representation of a resource. - */ - final case object ExpandedJsonLd extends ArchiveResourceRepresentation { - override def extension: String = ".json" - - override val toString: String = "expanded" - } - - /** - * NTriples representation of a resource. - */ - final case object NTriples extends ArchiveResourceRepresentation { - override def extension: String = ".nt" - - override val toString: String = "n-triples" - } - - final case object NQuads extends ArchiveResourceRepresentation { - override def extension: String = ".nq" - - override val toString: String = "n-quads" - } - - /** - * Dot representation of a resource. - */ - final case object Dot extends ArchiveResourceRepresentation { - override def extension: String = ".dot" - - override val toString: String = "dot" - } - - implicit final val archiveResourceRepresentationJsonLdDecoder: JsonLdDecoder[ArchiveResourceRepresentation] = - JsonLdDecoder.stringJsonLdDecoder.andThen { (cursor, str) => - str match { - case SourceJson.toString => Right(SourceJson) - case CompactedJsonLd.toString => Right(CompactedJsonLd) - case ExpandedJsonLd.toString => Right(ExpandedJsonLd) - case NTriples.toString => Right(NTriples) - case NQuads.toString => Right(NQuads) - case Dot.toString => Right(Dot) - case other => Left(ParsingFailure("Format", other, cursor.history)) - } - } - - implicit final val archiveResourceRepresentationEncoder: Encoder[ArchiveResourceRepresentation] = - Encoder.encodeString.contramap { - _.toString - } - - implicit val archiveResourceRepresentationOrder: Order[ArchiveResourceRepresentation] = Order.by(_.toString) -} diff --git a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveState.scala b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveState.scala index 1c4f5b5db0..49d6b26768 100644 --- a/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveState.scala +++ b/delta/plugins/archive/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveState.scala @@ -4,7 +4,7 @@ import cats.data.NonEmptySet import ch.epfl.bluebrain.nexus.delta.plugins.archive.model import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri import ch.epfl.bluebrain.nexus.delta.rdf.instances._ -import ch.epfl.bluebrain.nexus.delta.sdk.model.{ResourceF, ResourceUris} +import ch.epfl.bluebrain.nexus.delta.sdk.model.{ResourceF, ResourceRepresentation, ResourceUris} import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, ProjectBase} import ch.epfl.bluebrain.nexus.delta.sourcing.Serializer import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject @@ -65,11 +65,11 @@ object ArchiveState { @nowarn("cat=unused") implicit val serializer: Serializer[Iri, ArchiveState] = { import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Database._ - implicit val configuration: Configuration = Serializer.circeConfiguration - implicit val archiveResourceRepresentation: Codec.AsObject[ArchiveResourceRepresentation] = - deriveConfiguredCodec[ArchiveResourceRepresentation] - implicit val archiveReferenceCodec: Codec.AsObject[ArchiveReference] = deriveConfiguredCodec[ArchiveReference] - implicit val codec: Codec.AsObject[ArchiveState] = deriveConfiguredCodec[ArchiveState] + implicit val configuration: Configuration = Serializer.circeConfiguration + implicit val archiveResourceRepresentation: Codec.AsObject[ResourceRepresentation] = + deriveConfiguredCodec[ResourceRepresentation] + implicit val archiveReferenceCodec: Codec.AsObject[ArchiveReference] = deriveConfiguredCodec[ArchiveReference] + implicit val codec: Codec.AsObject[ArchiveState] = deriveConfiguredCodec[ArchiveState] Serializer() } diff --git a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownloadSpec.scala b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownloadSpec.scala index e5c64d3622..a549e747de 100644 --- a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownloadSpec.scala +++ b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchiveDownloadSpec.scala @@ -11,7 +11,7 @@ import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils.encode import ch.epfl.bluebrain.nexus.delta.plugins.archive.FileSelf.ParsingError import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveReference.{FileReference, FileSelfReference, ResourceReference} import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveRejection.{AuthorizationFailed, FilenameTooLong, InvalidFileSelf, ResourceNotFound} -import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveResourceRepresentation.{CompactedJsonLd, Dot, ExpandedJsonLd, NQuads, NTriples, SourceJson} +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation.{CompactedJsonLd, Dot, ExpandedJsonLd, NQuads, NTriples, SourceJson} import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.{ArchiveFormat, ArchiveRejection, ArchiveValue} import ch.epfl.bluebrain.nexus.delta.plugins.storage.RemoteContextResolutionFixture import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes.FileAttributesOrigin.Client diff --git a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesDecodingSpec.scala b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesDecodingSpec.scala index b9414611b1..318d4d7226 100644 --- a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesDecodingSpec.scala +++ b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesDecodingSpec.scala @@ -4,7 +4,7 @@ import cats.data.NonEmptySet import ch.epfl.bluebrain.nexus.delta.kernel.utils.UUIDF import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveReference.{FileReference, ResourceReference} import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveRejection.{DecodingFailed, InvalidJsonLdFormat, UnexpectedArchiveId} -import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveResourceRepresentation.{CompactedJsonLd, Dot, ExpandedJsonLd, NTriples, SourceJson} +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation.{CompactedJsonLd, Dot, ExpandedJsonLd, NTriples, SourceJson} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.AbsolutePath import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv import ch.epfl.bluebrain.nexus.delta.rdf.implicits._ diff --git a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesSTMSpec.scala b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesSTMSpec.scala index 4cbcc490a4..bed5a9c895 100644 --- a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesSTMSpec.scala +++ b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/ArchivesSTMSpec.scala @@ -2,7 +2,7 @@ package ch.epfl.bluebrain.nexus.delta.plugins.archive import cats.data.NonEmptySet import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveReference.ResourceReference -import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.ArchiveResourceRepresentation.SourceJson +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation.SourceJson import ch.epfl.bluebrain.nexus.delta.plugins.archive.model.{ArchiveState, ArchiveValue, CreateArchive} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.AbsolutePath import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ diff --git a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveSerializationSuite.scala b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveSerializationSuite.scala index 676156a243..09c5a6f1f6 100644 --- a/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveSerializationSuite.scala +++ b/delta/plugins/archive/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/archive/model/ArchiveSerializationSuite.scala @@ -6,6 +6,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.AbsolutePath import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv import ch.epfl.bluebrain.nexus.delta.sdk.SerializationSuite import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Subject, User} import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef} @@ -27,7 +28,7 @@ class ArchiveSerializationSuite extends SerializationSuite { ResourceRef.Revision(iri"$resourceId?rev=1", resourceId, 1), Some(anotherProject), absolutePath, - Some(ArchiveResourceRepresentation.CompactedJsonLd) + Some(ResourceRepresentation.CompactedJsonLd) ) private val fileSelfReference = FileSelfReference(uri"https://bbp.epfl.ch/nexus/org/proj/file", absolutePath) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala index 71c71486b4..6c5ad9c3fb 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala @@ -102,7 +102,7 @@ trait DeltaDirectives extends UriDirectives { def unacceptedMediaTypeRejection(values: Seq[MediaType]): UnacceptedResponseContentTypeRejection = UnacceptedResponseContentTypeRejection(values.map(mt => Alternative(mt)).toSet) - private[directives] def requestMediaType: Directive1[MediaType] = + def requestMediaType: Directive1[MediaType] = extractRequest.flatMap { req => HeadersUtils.findFirst(req.headers, mediaTypes) match { case Some(value) => provide(value) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/ResourceRepresentation.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/ResourceRepresentation.scala new file mode 100644 index 0000000000..5257c1b07c --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/ResourceRepresentation.scala @@ -0,0 +1,99 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.model + +import cats.Order +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.JsonLdDecoder +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.JsonLdDecoderError.ParsingFailure +import io.circe.{Decoder, Encoder} + +/** + * Enumeration of representations for resources. + */ +sealed trait ResourceRepresentation extends Product with Serializable { + + /** + * Default extension for the format + */ + def extension: String + +} + +object ResourceRepresentation { + + /** + * Source representation of a resource. + */ + final case object SourceJson extends ResourceRepresentation { + override def extension: String = ".json" + + override val toString: String = "source" + } + + /** + * Compacted JsonLD representation of a resource. + */ + final case object CompactedJsonLd extends ResourceRepresentation { + override def extension: String = ".json" + + override val toString: String = "compacted" + } + + /** + * Expanded JsonLD representation of a resource. + */ + final case object ExpandedJsonLd extends ResourceRepresentation { + override def extension: String = ".json" + + override val toString: String = "expanded" + } + + /** + * NTriples representation of a resource. + */ + final case object NTriples extends ResourceRepresentation { + override def extension: String = ".nt" + + override val toString: String = "n-triples" + } + + final case object NQuads extends ResourceRepresentation { + override def extension: String = ".nq" + + override val toString: String = "n-quads" + } + + /** + * Dot representation of a resource. + */ + final case object Dot extends ResourceRepresentation { + override def extension: String = ".dot" + + override val toString: String = "dot" + } + + private def parse(value: String) = + value match { + case SourceJson.toString => Right(SourceJson) + case CompactedJsonLd.toString => Right(CompactedJsonLd) + case ExpandedJsonLd.toString => Right(ExpandedJsonLd) + case NTriples.toString => Right(NTriples) + case NQuads.toString => Right(NQuads) + case Dot.toString => Right(Dot) + case other => Left(s"$other is not a valid representation") + } + + implicit final val resourceRepresentationJsonLdDecoder: JsonLdDecoder[ResourceRepresentation] = + JsonLdDecoder.stringJsonLdDecoder.andThen { (cursor, str) => + parse(str).leftMap(_ => ParsingFailure("Format", str, cursor.history)) + } + + implicit final val resourceRepresentationDecoder: Decoder[ResourceRepresentation] = + Decoder.decodeString.emap(parse) + + implicit final val resourceRepresentationEncoder: Encoder[ResourceRepresentation] = + Encoder.encodeString.contramap { + _.toString + } + + implicit val resourceRepresentationOrder: Order[ResourceRepresentation] = Order.by(_.toString) +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/MultiFetch.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/MultiFetch.scala new file mode 100644 index 0000000000..22bcbee928 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/MultiFetch.scala @@ -0,0 +1,51 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.multifetch + +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller +import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdContent +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchResponse.Result._ +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.{MultiFetchRequest, MultiFetchResponse} +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.resources +import monix.bio.UIO + +/** + * Allows to fetch multiple resources of different types in one request. + * + * The response includes a resources array that contains the resources in the order specified in the request. If there + * is a failure getting a particular resource, the error is included in place of the resource. + */ +trait MultiFetch { + + def apply(request: MultiFetchRequest)(implicit caller: Caller): UIO[MultiFetchResponse] + +} + +object MultiFetch { + def apply( + aclCheck: AclCheck, + fetchResource: MultiFetchRequest.Input => UIO[Option[JsonLdContent[_, _]]] + ): MultiFetch = + new MultiFetch { + override def apply(request: MultiFetchRequest)(implicit + caller: Caller + ): UIO[MultiFetchResponse] = { + val fetchAllCached = aclCheck.fetchAll.memoizeOnSuccess + request.resources + .traverse { input => + aclCheck.authorizeFor(input.project, resources.read, fetchAllCached).flatMap { + case true => + fetchResource(input).map { + _.map(Success(input.id, input.project, _)) + .getOrElse(NotFound(input.id, input.project)) + } + case false => + UIO.pure(AuthorizationFailed(input.id, input.project)) + } + } + .map { resources => + MultiFetchResponse(request.format, resources) + } + } + + } +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/model/MultiFetchRequest.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/model/MultiFetchRequest.scala new file mode 100644 index 0000000000..48aba61ec4 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/model/MultiFetchRequest.scala @@ -0,0 +1,36 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model + +import cats.data.NonEmptyList +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchRequest.Input +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef} +import io.circe.Decoder + +import scala.annotation.nowarn + +/** + * Request to get multiple resources + * @param format + * the output format for these resources + * @param resources + * the list of resources + */ +final case class MultiFetchRequest(format: ResourceRepresentation, resources: NonEmptyList[Input]) {} + +object MultiFetchRequest { + + def apply(representation: ResourceRepresentation, first: Input, others: Input*) = + new MultiFetchRequest(representation, NonEmptyList.of(first, others: _*)) + + final case class Input(id: ResourceRef, project: ProjectRef) + + @nowarn("cat=unused") + implicit val multiFetchRequestDecoder: Decoder[MultiFetchRequest] = { + import io.circe.generic.extras.Configuration + import io.circe.generic.extras.semiauto._ + implicit val cfg: Configuration = Configuration.default + implicit val inputDecoder = deriveConfiguredDecoder[Input] + deriveConfiguredDecoder[MultiFetchRequest] + } + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/model/MultiFetchResponse.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/model/MultiFetchResponse.scala new file mode 100644 index 0000000000..17ba74ed08 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/model/MultiFetchResponse.scala @@ -0,0 +1,118 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model + +import cats.data.NonEmptyList +import ch.epfl.bluebrain.nexus.delta.rdf.RdfError +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi} +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder +import ch.epfl.bluebrain.nexus.delta.rdf.syntax.jsonLdEncoderSyntax +import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdContent +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceRepresentation.{CompactedJsonLd, Dot, ExpandedJsonLd, NQuads, NTriples, SourceJson} +import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceRepresentation} +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchResponse.Result +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchResponse.Result.itemEncoder +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef} +import io.circe.syntax.EncoderOps +import io.circe.{Encoder, Json, JsonObject} +import monix.bio.{IO, UIO} + +/** + * A response for a multi-fetch operation + * @param format + * the formats in which the resource should be represented + * @param resources + * the result for each resource + */ +final case class MultiFetchResponse(format: ResourceRepresentation, resources: NonEmptyList[Result]) { + + /** + * Encode the response as a Json payload + */ + def asJson(implicit base: BaseUri, rcr: RemoteContextResolution): UIO[Json] = { + val encodeItem = itemEncoder(format) + resources.traverse(encodeItem).map { r => + Json.obj( + "format" -> format.asJson, + "resources" -> r.asJson + ) + } + }.hideErrors +} + +object MultiFetchResponse { + + sealed trait Result { + + def id: ResourceRef + + def project: ProjectRef + } + + object Result { + + sealed trait Error extends Result { + def reason: String + } + + final case class AuthorizationFailed(id: ResourceRef, project: ProjectRef) extends Error { + override def reason: String = "The supplied authentication is not authorized to access this resource." + } + + final case class NotFound(id: ResourceRef, project: ProjectRef) extends Error { + override def reason: String = s"The resource '${id.toString}' was not found in project '$project'." + } + + final case class Success[A](id: ResourceRef, project: ProjectRef, content: JsonLdContent[A, _]) extends Result + + implicit private val itemErrorEncoder: Encoder.AsObject[Error] = { + Encoder.AsObject.instance[Error] { r => + JsonObject( + "@type" -> Json.fromString(r.getClass.getSimpleName), + "reason" -> Json.fromString(r.reason) + ) + } + } + + implicit val itemErrorJsonLdEncoder: JsonLdEncoder[Error] = { + JsonLdEncoder.computeFromCirce(ContextValue(contexts.error)) + } + + implicit private val api: JsonLdApi = JsonLdJavaApi.lenient + + private[model] def itemEncoder(repr: ResourceRepresentation)(implicit base: BaseUri, rcr: RemoteContextResolution) = + (item: Result) => { + val common = JsonObject( + "@id" -> item.id.asJson, + "project" -> item.project.asJson + ) + + def valueToJson[A](value: JsonLdContent[A, _]): IO[RdfError, Json] = { + implicit val encoder: JsonLdEncoder[A] = value.encoder + toJson(value.resource, value.source) + } + + def toJson[C, S](value: C, source: S)(implicit + valueJsonLdEncoder: JsonLdEncoder[C], + sourceEncoder: Encoder[S] + ): IO[RdfError, Json] = + repr match { + case SourceJson => UIO.pure(source.asJson) + case CompactedJsonLd => value.toCompactedJsonLd.map { v => v.json } + case ExpandedJsonLd => value.toExpandedJsonLd.map { v => v.json } + case NTriples => value.toNTriples.map { v => v.value.asJson } + case NQuads => value.toNQuads.map { v => v.value.asJson } + case Dot => value.toDot.map { v => v.value.asJson } + } + + val result = item match { + case e: Error => toJson(e, e).map { e => JsonObject("error" -> e) } + case Success(_, _, content) => valueToJson(content).map { r => JsonObject("value" -> r) } + } + + result.map(_.deepMerge(common)) + } + + } + +} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/generators/ResourceGen.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/generators/ResourceGen.scala index be576cc180..6ceb1ac7f3 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/generators/ResourceGen.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/generators/ResourceGen.scala @@ -6,6 +6,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.ExpandedJsonLd import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.sdk.DataResource +import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdContent import ch.epfl.bluebrain.nexus.delta.sdk.model.Tags import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, ProjectBase} import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.{Resource, ResourceState} @@ -128,4 +129,9 @@ object ResourceGen extends IOValues { subject ).toResource(am, ProjectBase.unsafe(base)) + def jsonLdContent(id: Iri, project: ProjectRef, source: Json)(implicit resolution: RemoteContextResolution) = { + val resourceF = sourceToResourceF(id, project, source) + JsonLdContent(resourceF, resourceF.value.source, None) + } + } diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/MultiFetchSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/MultiFetchSuite.scala new file mode 100644 index 0000000000..84ba553549 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/multifetch/MultiFetchSuite.scala @@ -0,0 +1,86 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.multifetch + +import cats.data.NonEmptyList +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck +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.ResourceRepresentation +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchRequest.Input +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.MultiFetchResponse.Result.{AuthorizationFailed, NotFound, Success} +import ch.epfl.bluebrain.nexus.delta.sdk.multifetch.model.{MultiFetchRequest, MultiFetchResponse} +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions +import ch.epfl.bluebrain.nexus.delta.sdk.utils.Fixtures +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject +import ch.epfl.bluebrain.nexus.delta.sourcing.model.ResourceRef.Latest +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Identity, Label, ProjectRef} +import ch.epfl.bluebrain.nexus.testkit.TestHelpers +import ch.epfl.bluebrain.nexus.testkit.bio.BioSuite +import monix.bio.UIO + +class MultiFetchSuite extends BioSuite with TestHelpers with Fixtures { + + implicit private val subject: Subject = Identity.User("user", Label.unsafe("realm")) + implicit private val caller: Caller = Caller.unsafe(subject) + + private val project1 = ProjectRef.unsafe("org", "proj1") + private val project2 = ProjectRef.unsafe("org", "proj2") + + private val permissions = Set(Permissions.resources.read) + private val aclCheck = AclSimpleCheck((subject, project1, permissions)).runSyncUnsafe() + + private val successId = nxv + "success" + private val successContent = + ResourceGen.jsonLdContent(successId, project1, jsonContentOf("resources/resource.json", "id" -> successId)) + private val notFoundId = nxv + "not-found" + private val unauthorizedId = nxv + "unauthorized" + + private def fetchResource = + (input: MultiFetchRequest.Input) => { + input match { + case MultiFetchRequest.Input(Latest(`successId`), `project1`) => + UIO.some(successContent) + case _ => UIO.none + } + } + + private val multiFetch = MultiFetch( + aclCheck, + fetchResource + ) + + private val request = MultiFetchRequest( + ResourceRepresentation.NTriples, + Input(Latest(successId), project1), + Input(Latest(notFoundId), project1), + Input(Latest(unauthorizedId), project2) + ) + + test("Return the response matching the user acls") { + + val expected = MultiFetchResponse( + ResourceRepresentation.NTriples, + NonEmptyList.of( + Success(Latest(successId), project1, successContent), + NotFound(Latest(notFoundId), project1), + AuthorizationFailed(Latest(unauthorizedId), project2) + ) + ) + + multiFetch(request).assert(expected) + } + + test("Return only unauthorized for a user with no access") { + val expected = MultiFetchResponse( + ResourceRepresentation.NTriples, + NonEmptyList.of( + AuthorizationFailed(Latest(successId), project1), + AuthorizationFailed(Latest(notFoundId), project1), + AuthorizationFailed(Latest(unauthorizedId), project2) + ) + ) + + multiFetch(request)(Caller.Anonymous).assert(expected) + } + +} diff --git a/docs/src/main/paradox/docs/delta/api/archives-api.md b/docs/src/main/paradox/docs/delta/api/archives-api.md index 80a7400395..30e9274b42 100644 --- a/docs/src/main/paradox/docs/delta/api/archives-api.md +++ b/docs/src/main/paradox/docs/delta/api/archives-api.md @@ -88,7 +88,8 @@ In order to decide whether we want to select a resource or a file, the `@type` d possibilities: - `Resource`: targets a resource -- `File`: targets a file +- `File`: targets a file using its project and id +- `FileSelf`: targets a file using its address (`_self`) ## Create using POST diff --git a/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/payload.json b/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/payload.json new file mode 100644 index 0000000000..7eb1afcaac --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/payload.json @@ -0,0 +1,17 @@ +{ + "format": "source", + "resources" : [ + { + "id": "https://bbp.epfl.ch/person/alex", + "project": "public/person" + }, + { + "id": "https://bbp.epfl.ch/person/john-doe", + "project": "public/person" + }, + { + "id": "https://bbp.epfl.ch/secret/xxx", + "project": "restricted/xxx" + } + ] +} \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/request.sh b/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/request.sh new file mode 100644 index 0000000000..1ec947ab42 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/request.sh @@ -0,0 +1,21 @@ +curl -L \ + -X GET \ + -d ' + { + "format": "source", + "resources" : [ + { + "id": "https://bbp.epfl.ch/person/alex", + "project": "public/person" + }, + { + "id": "https://bbp.epfl.ch/person/john-doe", + "project": "public/person" + }, + { + "id": "https://bbp.epfl.ch/secret/xxx", + "project": "restricted/xxx" + } + ] + } +' \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/response.json b/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/response.json new file mode 100644 index 0000000000..327120d043 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/multi-fetch/response.json @@ -0,0 +1,35 @@ +{ + "format": "source", + "resources": [ + { + "@id": "https://bbp.epfl.ch/person/alex", + "project": "public/person", + "value": { + "@context": { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + }, + "@id": "https://bluebrain.github.io/nexus/vocabulary/success", + "@type": "Person", + "bool": false, + "name": "Alex", + "number": 24 + } + }, + { + "@id": "https://bbp.epfl.ch/person/john-doe", + "project": "public/person", + "error": { + "@type": "NotFound", + "reason": "The resource 'https://bbp.epfl.ch/person/john-doe' was not found in project 'public/person'." + } + }, + { + "@id": "https://bbp.epfl.ch/secret/xxx", + "project": "restricted/xxx", + "error": { + "@type": "AuthorizationFailed", + "reason": "The supplied authentication is not authorized to access this resource." + } + } + ] +} \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/index.md b/docs/src/main/paradox/docs/delta/api/index.md index f5fba971d5..57ab90a186 100644 --- a/docs/src/main/paradox/docs/delta/api/index.md +++ b/docs/src/main/paradox/docs/delta/api/index.md @@ -13,6 +13,7 @@ * @ref:[Quotas](quotas.md) * @ref:[Schemas](schemas-api.md) * @ref:[Resources](resources-api.md) +* @ref:[Multi-fetch](multi-fetch.md) * @ref:[Resolvers](resolvers-api.md) * @ref:[Views](views/index.md) * @ref:[Storages](storages-api.md) diff --git a/docs/src/main/paradox/docs/delta/api/multi-fetch.md b/docs/src/main/paradox/docs/delta/api/multi-fetch.md new file mode 100644 index 0000000000..4288a829a7 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/multi-fetch.md @@ -0,0 +1,58 @@ +# Multi fetch + +The multi-fetch operation allows to get in a given format multiple resources that can live in multiple projects. + +The response includes a resources array that contains the resources in the order specified in the request. +The structure of the returned resources is similar to that returned by the fetch API. +If there is a failure getting a particular resource, the error is included in place of the resource. + +This operation can be used to return every type of resource. + +@@@ note { .tip title="Authorization notes" } + +When performing a request, the caller must have `resources/read` permission on the project each resource belongs to. + +Please visit @ref:[Authentication & authorization](authentication.md) section to learn more about it. + +@@@ + +## Payload + +``` +GET /v1/multi-fetch/resources + +{ + "format": {format} + "resources": [ + { + "id": "{id}", + "project": "{project}" + }, + ... + ] +} +``` + +where... + +- `{format}`: String - the format we expect for the resources in the response. +Accepts the following values: source (to get the original payload), compacted, expanded, n-triples, dot +- `{project}`: String - the project (in the format 'myorg/myproject') where the specified resource belongs. This field + is optional. It defaults to the current project. +- `{id}`: Iri - the @id value of the resource to be returned. Can contain a tag or a revision. + +## Example + +The following example shows how to perform a multi-fetch and an example of response +containing errors (missing permissions and resource not found). +As a response, a regular json is returned containing the different resources in the requested format. + +Request +: @@snip [request.sh](assets/multi-fetch/request.sh) + +Payload +: @@snip [payload.json](assets/multi-fetch/payload.json) + +Response +: @@snip [response.json](assets/multi-fetch/response.json) + diff --git a/docs/src/main/paradox/docs/releases/index.md b/docs/src/main/paradox/docs/releases/index.md index a57338979d..11fcf42b50 100644 --- a/docs/src/main/paradox/docs/releases/index.md +++ b/docs/src/main/paradox/docs/releases/index.md @@ -31,8 +31,9 @@ The latest stable release is **v1.8.0** released on **14.06.2023**. ### New features / enhancements - @ref:[Aggregations of resources by `@type` and `project`](../delta/api/resources-api.md#aggregations) -- @ref:[Resources can be added to an archive using `_self`](../delta/api/archives-api.md#payload) +- @ref:[Files can be added to an archive using `_self`](../delta/api/archives-api.md#payload) - @ref:[Indexing errors can now be listed and filtered](../delta/api/views/index.md#listing-indexing-failures) +- @ref:[Multi fetch operation allows to get multiple resources in a single call](../delta/api/multi-fetch.md) ## 1.8.0 (14.06.2023) diff --git a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md index 06ff1196a3..ba21f656db 100644 --- a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md +++ b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md @@ -8,6 +8,12 @@ TODO add potential migration page ### Resources +#### Multi fetch + +Multiple resources can now be retrieved within a single call with the multi-fetch operation. + +@ref:[More information](../delta/api/multi-fetch.md) + #### Payload validation It is now forbidden for JSON payloads to contain fields beginning with underscore (_). This can be disabled be setting `app.resources.decoding-option` to `lenient`, however it is not recommended as specification of this data in payloads can have unexpected consequences in both data and the user-interface diff --git a/tests/src/test/resources/kg/multi-fetch/all-success.json b/tests/src/test/resources/kg/multi-fetch/all-success.json new file mode 100644 index 0000000000..4ad49b30f6 --- /dev/null +++ b/tests/src/test/resources/kg/multi-fetch/all-success.json @@ -0,0 +1,40 @@ +{ + "format" : "source", + "resources" : [ + { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/resource?tag=v1.0.0", + "project" : "{{project1}}", + "value" : { + "@context" : { + "nxv" : "https://bluebrain.github.io/nexus/vocabulary/", + "other" : "https://some.other.prefix.com/" + }, + "@type" : "nxv:TestResource", + "other:priority" : 5, + "other:projects" : [ + "testProject", + "testProject2" + ] + } + }, + { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/file", + "project" : "{{project2}}", + "value" : { + "_bytes" : 47, + "_digest" : { + "_algorithm" : "SHA-256", + "_value" : "00ff4b34e3f3695c3abcdec61cba72c2238ed172ef34ae1196bfad6a4ec23dda" + }, + "_filename" : "attachment.json", + "_mediaType" : "application/json", + "_origin" : "Client", + "_storage" : { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/diskStorageDefault", + "@type" : "https://bluebrain.github.io/nexus/vocabulary/DiskStorage", + "_rev" : 1 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/src/test/resources/kg/multi-fetch/limited-access.json b/tests/src/test/resources/kg/multi-fetch/limited-access.json new file mode 100644 index 0000000000..db3dc24fcf --- /dev/null +++ b/tests/src/test/resources/kg/multi-fetch/limited-access.json @@ -0,0 +1,32 @@ +{ + "format" : "source", + "resources" : [ + { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/resource?tag=v1.0.0", + "project" : "{{project1}}", + "error" : { + "@type" : "AuthorizationFailed", + "reason" : "The supplied authentication is not authorized to access this resource." + } + }, + { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/file", + "project" : "{{project2}}", + "value" : { + "_bytes" : 47, + "_digest" : { + "_algorithm" : "SHA-256", + "_value" : "00ff4b34e3f3695c3abcdec61cba72c2238ed172ef34ae1196bfad6a4ec23dda" + }, + "_filename" : "attachment.json", + "_mediaType" : "application/json", + "_origin" : "Client", + "_storage" : { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/diskStorageDefault", + "@type" : "https://bluebrain.github.io/nexus/vocabulary/DiskStorage", + "_rev" : 1 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/src/test/resources/kg/multi-fetch/unknown.json b/tests/src/test/resources/kg/multi-fetch/unknown.json new file mode 100644 index 0000000000..2fa4110f85 --- /dev/null +++ b/tests/src/test/resources/kg/multi-fetch/unknown.json @@ -0,0 +1,13 @@ +{ + "format" : "source", + "resources" : [ + { + "@id" : "https://bluebrain.github.io/nexus/vocabulary/xxx", + "project" : "{{project1}}", + "error": { + "@type": "NotFound", + "reason": "The resource 'https://bluebrain.github.io/nexus/vocabulary/xxx' was not found in project '{{project1}}'." + } + } + ] +} \ No newline at end of file diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/HttpClient.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/HttpClient.scala index cc3a03699d..5e12991ed9 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/HttpClient.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/HttpClient.scala @@ -117,6 +117,11 @@ class HttpClient private (baseUrl: Uri, httpExt: HttpExt)(implicit as: ActorSyst )(implicit um: FromEntityUnmarshaller[A]): Task[Assertion] = requestAssert(PATCH, url, Some(body), identity, extraHeaders)(assertResponse) + def getWithBody[A](url: String, body: Json, identity: Identity, extraHeaders: Seq[HttpHeader] = jsonHeaders)( + assertResponse: (A, HttpResponse) => Assertion + )(implicit um: FromEntityUnmarshaller[A]): Task[Assertion] = + requestAssert(GET, url, Some(body), identity, extraHeaders)(assertResponse) + def get[A](url: String, identity: Identity, extraHeaders: Seq[HttpHeader] = jsonHeaders)( assertResponse: (A, HttpResponse) => Assertion )(implicit um: FromEntityUnmarshaller[A]): Task[Assertion] = diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala index 0c8de754bc..08c3a86a42 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala @@ -21,11 +21,16 @@ object Optics extends Optics { val filtered = keys.foldLeft(jsonObject) { (o, k) => o.remove(k) } JsonObject.fromIterable( filtered.toList.map { case (k, v) => - v.asObject.fold(k -> v) { o => - k -> Json.fromJsonObject( - inner(o) - ) - } + v.arrayOrObject( + k -> v, + a => + k -> Json.fromValues( + a.map { element => + element.asObject.fold(element) { e => Json.fromJsonObject(inner(e)) } + } + ), + o => k -> Json.fromJsonObject(inner(o)) + ) } ) } diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/MultiFetchSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/MultiFetchSpec.scala new file mode 100644 index 0000000000..3756e3796e --- /dev/null +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/MultiFetchSpec.scala @@ -0,0 +1,112 @@ +package ch.epfl.bluebrain.nexus.tests.kg + +import akka.http.scaladsl.model.{ContentTypes, StatusCodes} +import ch.epfl.bluebrain.nexus.tests.BaseSpec +import ch.epfl.bluebrain.nexus.tests.Identity.listings.{Alice, Bob} +import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.{Organizations, Resources} +import io.circe.Json +import ch.epfl.bluebrain.nexus.tests.Optics._ + +class MultiFetchSpec extends BaseSpec { + + private val org1 = genId() + private val proj11 = genId() + private val proj12 = genId() + private val ref11 = s"$org1/$proj11" + private val ref12 = s"$org1/$proj12" + + private val prefix = "https://bluebrain.github.io/nexus/vocabulary/" + + override def beforeAll(): Unit = { + super.beforeAll() + + val setup = for { + _ <- aclDsl.addPermission("/", Bob, Organizations.Create) + _ <- adminDsl.createOrganization(org1, org1, Bob) + _ <- adminDsl.createProject(org1, proj11, kgDsl.projectJson(name = proj11), Bob) + _ <- adminDsl.createProject(org1, proj12, kgDsl.projectJson(name = proj12), Bob) + _ <- aclDsl.addPermission(s"/$ref12", Alice, Resources.Read) + } yield () + + val resourcePayload = + jsonContentOf( + "/kg/resources/simple-resource.json", + "priority" -> "5" + ) + + val createResources = for { + // Creation + _ <- deltaClient.put[Json](s"/resources/$ref11/_/nxv:resource", resourcePayload, Bob)(expectCreated) + _ <- deltaClient.putAttachment[Json]( + s"/files/$ref12/nxv:file", + contentOf("/kg/files/attachment.json"), + ContentTypes.`application/json`, + "attachment.json", + Bob + )(expectCreated) + // Tag + _ <- deltaClient.post[Json](s"/resources/$ref11/_/nxv:resource/tags?rev=1", tag("v1.0.0", 1), Bob)(expectCreated) + } yield () + + (setup >> createResources).accepted + } + + "Fetching multiple resources" should { + + def request(format: String) = + json""" + { + "format": "$format", + "resources": [ + { "id": "${prefix}resource?tag=v1.0.0", "project": "$ref11" }, + { "id": "${prefix}file", "project": "$ref12" } + ] + }""" + + "get all resources for a user with all access" in { + val expected = jsonContentOf( + "/kg/multi-fetch/all-success.json", + "project1" -> ref11, + "project2" -> ref12 + ) + + deltaClient.getWithBody[Json]("/multi-fetch/resources", request("source"), Bob) { (json, response) => + response.status shouldEqual StatusCodes.OK + filterNestedKeys("_uuid")(json) shouldEqual expected + } + } + + "get all resources for a user with limited access" in { + val expected = jsonContentOf( + "/kg/multi-fetch/limited-access.json", + "project1" -> ref11, + "project2" -> ref12 + ) + + deltaClient.getWithBody[Json]("/multi-fetch/resources", request("source"), Alice) { (json, response) => + response.status shouldEqual StatusCodes.OK + filterNestedKeys("_uuid")(json) shouldEqual expected + } + } + + "get a not found error for an non-existing-resource" in { + val request = + json""" + { + "format": "source", + "resources": [ + { "id": "${prefix}xxx", "project": "$ref11" } + ] + }""" + + val expected = jsonContentOf("/kg/multi-fetch/unknown.json", "project1" -> ref11) + + deltaClient.getWithBody[Json]("/multi-fetch/resources", request, Bob) { (json, response) => + response.status shouldEqual StatusCodes.OK + json shouldEqual expected + } + } + + } + +}