From 764c1218b9c396bd75319ab0624b473c701e73fc Mon Sep 17 00:00:00 2001 From: dantb Date: Mon, 16 Oct 2023 16:28:25 +0200 Subject: [PATCH] Allow creating/linking file with tag --- .../nexus/delta/kernel/utils/IOUtils.scala | 8 ++ .../delta/plugins/storage/files/Files.scala | 46 +++++--- .../storage/files/model/FileCommand.scala | 3 +- .../storage/files/model/FileEvent.scala | 5 +- .../storage/files/routes/FilesRoutes.scala | 20 ++-- .../files/database/file-created-tagged.json | 27 +++++ .../files/database/file-created.json | 1 + .../files/sse/file-created-tagged.json | 30 +++++ .../resources/files/sse/file-created.json | 1 + .../plugins/storage/files/FileFixtures.scala | 72 +++++++----- .../plugins/storage/files/FilesSpec.scala | 111 ++++++++++++++---- .../plugins/storage/files/FilesStmSpec.scala | 8 +- .../files/model/FileSerializationSuite.scala | 9 +- .../files/routes/FilesRoutesSpec.scala | 36 +++++- .../nexus/delta/sdk/model/Tags.scala | 2 + .../nexus/delta/sdk/resources/Resources.scala | 7 +- 16 files changed, 298 insertions(+), 88 deletions(-) create mode 100644 delta/plugins/storage/src/test/resources/files/database/file-created-tagged.json create mode 100644 delta/plugins/storage/src/test/resources/files/sse/file-created-tagged.json diff --git a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/IOUtils.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/IOUtils.scala index bf96f2c7d2..fd4b9dd7ce 100644 --- a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/IOUtils.scala +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/IOUtils.scala @@ -74,4 +74,12 @@ object UUIDF { override def apply(): UIO[UUID] = uuidRef.get.hideErrors } } yield uuidF).hideErrors + + /** + * Creates a [[UUIDF]] sourcing [[UUID]] values from a mutable reference. + * + * @param ref + * the pre-initialised mutable reference used to store the [[UUID]] + */ + final def fromRef(ref: Ref[Task, UUID]): UUIDF = () => ref.get.hideErrors } diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala index 48a69042ad..acbe3469fb 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala @@ -85,19 +85,22 @@ final class Files( * the project where the file will belong * @param entity * the http FormData entity + * @param tag + * the optional tag this file is being created with, attached to the current revision */ def create( storageId: Option[IdSegment], projectRef: ProjectRef, - entity: HttpEntity + entity: HttpEntity, + tag: Option[UserTag] )(implicit caller: Caller): IO[FileRejection, FileResource] = { for { pc <- fetchContext.onCreate(projectRef) iri <- generateId(pc) - _ <- test(CreateFile(iri, projectRef, testStorageRef, testStorageType, testAttributes, caller.subject)) + _ <- test(CreateFile(iri, projectRef, testStorageRef, testStorageType, testAttributes, caller.subject, tag)) (storageRef, storage) <- fetchActiveStorage(storageId, projectRef, pc) attributes <- extractFileAttributes(iri, entity, storage) - res <- eval(CreateFile(iri, projectRef, storageRef, storage.tpe, attributes, caller.subject)) + res <- eval(CreateFile(iri, projectRef, storageRef, storage.tpe, attributes, caller.subject, tag)) } yield res }.span("createFile") @@ -112,20 +115,23 @@ final class Files( * the project where the file will belong * @param entity * the http FormData entity + * @param tag + * the optional tag this file is being created with, attached to the current revision */ def create( id: IdSegment, storageId: Option[IdSegment], projectRef: ProjectRef, - entity: HttpEntity + entity: HttpEntity, + tag: Option[UserTag] )(implicit caller: Caller): IO[FileRejection, FileResource] = { for { pc <- fetchContext.onCreate(projectRef) iri <- expandIri(id, pc) - _ <- test(CreateFile(iri, projectRef, testStorageRef, testStorageType, testAttributes, caller.subject)) + _ <- test(CreateFile(iri, projectRef, testStorageRef, testStorageType, testAttributes, caller.subject, tag)) (storageRef, storage) <- fetchActiveStorage(storageId, projectRef, pc) attributes <- extractFileAttributes(iri, entity, storage) - res <- eval(CreateFile(iri, projectRef, storageRef, storage.tpe, attributes, caller.subject)) + res <- eval(CreateFile(iri, projectRef, storageRef, storage.tpe, attributes, caller.subject, tag)) } yield res }.span("createFile") @@ -142,18 +148,21 @@ final class Files( * the optional media type to use * @param path * the path where the file is located inside the storage + * @param tag + * the optional tag this file link is being created with, attached to the current revision */ def createLink( storageId: Option[IdSegment], projectRef: ProjectRef, filename: Option[String], mediaType: Option[ContentType], - path: Uri.Path + path: Uri.Path, + tag: Option[UserTag] )(implicit caller: Caller): IO[FileRejection, FileResource] = { for { pc <- fetchContext.onCreate(projectRef) iri <- generateId(pc) - res <- createLink(iri, projectRef, pc, storageId, filename, mediaType, path) + res <- createLink(iri, projectRef, pc, storageId, filename, mediaType, path, tag) } yield res }.span("createLink") @@ -172,6 +181,8 @@ final class Files( * the optional media type to use * @param path * the path where the file is located inside the storage + * @param tag + * the optional tag this file link is being created with, attached to the current revision */ def createLink( id: IdSegment, @@ -179,12 +190,13 @@ final class Files( projectRef: ProjectRef, filename: Option[String], mediaType: Option[ContentType], - path: Uri.Path + path: Uri.Path, + tag: Option[UserTag] )(implicit caller: Caller): IO[FileRejection, FileResource] = { for { pc <- fetchContext.onCreate(projectRef) iri <- expandIri(id, pc) - res <- createLink(iri, projectRef, pc, storageId, filename, mediaType, path) + res <- createLink(iri, projectRef, pc, storageId, filename, mediaType, path, tag) } yield res }.span("createLink") @@ -388,17 +400,18 @@ final class Files( storageId: Option[IdSegment], filename: Option[String], mediaType: Option[ContentType], - path: Uri.Path + path: Uri.Path, + tag: Option[UserTag] )(implicit caller: Caller): IO[FileRejection, FileResource] = for { - _ <- test(CreateFile(iri, ref, testStorageRef, testStorageType, testAttributes, caller.subject)) + _ <- test(CreateFile(iri, ref, testStorageRef, testStorageType, testAttributes, caller.subject, tag)) (storageRef, storage) <- fetchActiveStorage(storageId, ref, pc) resolvedFilename <- IO.fromOption(filename.orElse(path.lastSegment), InvalidFileLink(iri)) description <- FileDescription(resolvedFilename, mediaType) attributes <- LinkFile(storage, remoteDiskStorageClient, config) .apply(path, description) .mapError(LinkRejection(iri, storage.id, _)) - res <- eval(CreateFile(iri, ref, storageRef, storage.tpe, attributes, caller.subject)) + res <- eval(CreateFile(iri, ref, storageRef, storage.tpe, attributes, caller.subject, tag)) } yield res private def eval(cmd: FileCommand): IO[FileRejection, FileResource] = @@ -567,7 +580,7 @@ object Files { ): Option[FileState] = { // format: off def created(e: FileCreated): Option[FileState] = Option.when(state.isEmpty) { - FileState(e.id, e.project, e.storage, e.storageType, e.attributes, Tags.empty, e.rev, deprecated = false, e.instant, e.subject, e.instant, e.subject) + FileState(e.id, e.project, e.storage, e.storageType, e.attributes, Tags(e.tag, e.rev), e.rev, deprecated = false, e.instant, e.subject, e.instant, e.subject) } def updated(e: FileUpdated): Option[FileState] = state.map { s => @@ -607,7 +620,9 @@ object Files { def create(c: CreateFile) = state match { case None => - IOUtils.instant.map(FileCreated(c.id, c.project, c.storage, c.storageType, c.attributes, 1, _, c.subject)) + IOUtils.instant.map( + FileCreated(c.id, c.project, c.storage, c.storageType, c.attributes, 1, _, c.subject, c.tag) + ) case Some(_) => IO.raiseError(ResourceAlreadyExists(c.id, c.project)) } @@ -684,6 +699,7 @@ object Files { FileState.serializer, Tagger[FileEvent]( { + case f: FileCreated => f.tag.flatMap(t => Some(t -> f.rev)) case f: FileTagAdded => Some(f.tag -> f.targetRev) case _ => None }, diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala index 6a3b929f3c..573e7ad3c1 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala @@ -62,7 +62,8 @@ object FileCommand { storage: ResourceRef.Revision, storageType: StorageType, attributes: FileAttributes, - subject: Subject + subject: Subject, + tag: Option[UserTag] ) extends FileCommand { override def rev: Int = 0 } diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala index a022f09a7b..f3034a1fb6 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala @@ -81,6 +81,8 @@ object FileEvent { * the instant this event was created * @param subject * the subject which created this event + * @param tag + * an optional tag attached at creation */ final case class FileCreated( id: Iri, @@ -90,7 +92,8 @@ object FileEvent { attributes: FileAttributes, rev: Int, instant: Instant, - subject: Subject + subject: Subject, + tag: Option[UserTag] ) extends FileEvent /** diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala index 8f6ab8b69c..d806843510 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala @@ -27,6 +27,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ import ch.epfl.bluebrain.nexus.delta.sdk.model.routes.Tag import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegment, IdSegmentRef, ResourceF} import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag import io.circe.Decoder import io.circe.generic.extras.Configuration import io.circe.generic.extras.semiauto.deriveConfiguredDecoder @@ -80,19 +81,19 @@ final class FilesRoutes( concat( (post & pathEndOrSingleSlash & noParameter("rev") & parameter( "storage".as[IdSegment].? - ) & indexingMode) { (storage, mode) => + ) & indexingMode & tagParam) { (storage, mode, tag) => operationName(s"$prefixSegment/files/{org}/{project}") { concat( // Link a file without id segment entity(as[LinkFile]) { case LinkFile(filename, mediaType, path) => emit( Created, - files.createLink(storage, ref, filename, mediaType, path).tapEval(indexUIO(ref, _, mode)) + files.createLink(storage, ref, filename, mediaType, path, tag).tapEval(indexUIO(ref, _, mode)) ) }, // Create a file without id segment extractRequestEntity { entity => - emit(Created, files.create(storage, ref, entity).tapEval(indexUIO(ref, _, mode))) + emit(Created, files.create(storage, ref, entity, tag).tapEval(indexUIO(ref, _, mode))) } ) } @@ -103,24 +104,27 @@ final class FilesRoutes( operationName(s"$prefixSegment/files/{org}/{project}/{id}") { concat( (put & pathEndOrSingleSlash) { - parameters("rev".as[Int].?, "storage".as[IdSegment].?) { - case (None, storage) => + parameters("rev".as[Int].?, "storage".as[IdSegment].?, "tag".as[UserTag].?) { + case (None, storage, tag) => concat( // Link a file with id segment entity(as[LinkFile]) { case LinkFile(filename, mediaType, path) => emit( Created, files - .createLink(id, storage, ref, filename, mediaType, path) + .createLink(id, storage, ref, filename, mediaType, path, tag) .tapEval(indexUIO(ref, _, mode)) ) }, // Create a file with id segment extractRequestEntity { entity => - emit(Created, files.create(id, storage, ref, entity).tapEval(indexUIO(ref, _, mode))) + emit( + Created, + files.create(id, storage, ref, entity, tag).tapEval(indexUIO(ref, _, mode)) + ) } ) - case (Some(rev), storage) => + case (Some(rev), storage, _) => concat( // Update a Link entity(as[LinkFile]) { case LinkFile(filename, mediaType, path) => diff --git a/delta/plugins/storage/src/test/resources/files/database/file-created-tagged.json b/delta/plugins/storage/src/test/resources/files/database/file-created-tagged.json new file mode 100644 index 0000000000..e21c6f2a88 --- /dev/null +++ b/delta/plugins/storage/src/test/resources/files/database/file-created-tagged.json @@ -0,0 +1,27 @@ +{ + "id" : "https://bluebrain.github.io/nexus/vocabulary/file", + "project" : "myorg/myproj", + "storage" : "https://bluebrain.github.io/nexus/vocabulary/disk-storage?rev=1", + "storageType" : "DiskStorage", + "tag" : "mytag", + "attributes" : { + "origin" : "Client", + "uuid" : "8049ba90-7cc6-4de5-93a1-802c04200dcc", + "location" : "http://localhost/file.txt", + "path" : "file.txt", + "filename" : "file.txt", + "mediaType" : "text/plain; charset=UTF-8", + "bytes" : 12, + "digest" : { + "@type" : "NotComputedDigest" + } + }, + "rev" : 1, + "instant" : "1970-01-01T00:00:00Z", + "subject" : { + "subject" : "username", + "realm" : "myrealm", + "@type" : "User" + }, + "@type" : "FileCreated" +} diff --git a/delta/plugins/storage/src/test/resources/files/database/file-created.json b/delta/plugins/storage/src/test/resources/files/database/file-created.json index 4c323ebfe8..16f4154946 100644 --- a/delta/plugins/storage/src/test/resources/files/database/file-created.json +++ b/delta/plugins/storage/src/test/resources/files/database/file-created.json @@ -3,6 +3,7 @@ "project" : "myorg/myproj", "storage" : "https://bluebrain.github.io/nexus/vocabulary/disk-storage?rev=1", "storageType" : "DiskStorage", + "tag" : null, "attributes" : { "origin" : "Client", "uuid" : "8049ba90-7cc6-4de5-93a1-802c04200dcc", diff --git a/delta/plugins/storage/src/test/resources/files/sse/file-created-tagged.json b/delta/plugins/storage/src/test/resources/files/sse/file-created-tagged.json new file mode 100644 index 0000000000..04fb502e79 --- /dev/null +++ b/delta/plugins/storage/src/test/resources/files/sse/file-created-tagged.json @@ -0,0 +1,30 @@ +{ + "@context": [ + "https://bluebrain.github.io/nexus/contexts/metadata.json", + "https://bluebrain.github.io/nexus/contexts/files.json" + ], + "@type": "FileCreated", + "tag": "mytag", + "_attributes": { + "_bytes": 12, + "_digest": { + "_value": "" + }, + "_filename": "file.txt", + "_mediaType": "text/plain; charset=UTF-8", + "_origin": "Client", + "_uuid": "8049ba90-7cc6-4de5-93a1-802c04200dcc" + }, + "_fileId": "https://bluebrain.github.io/nexus/vocabulary/file", + "_instant": "1970-01-01T00:00:00Z", + "_project": "http://localhost/v1/projects/myorg/myproj", + "_resourceId": "https://bluebrain.github.io/nexus/vocabulary/file", + "_rev": 1, + "_storage": { + "@id": "https://bluebrain.github.io/nexus/vocabulary/disk-storage", + "@type": "DiskStorage", + "_resourceId": "https://bluebrain.github.io/nexus/vocabulary/disk-storage", + "_rev": 1 + }, + "_subject": "http://localhost/v1/realms/myrealm/users/username" +} \ No newline at end of file diff --git a/delta/plugins/storage/src/test/resources/files/sse/file-created.json b/delta/plugins/storage/src/test/resources/files/sse/file-created.json index 1ab5ab912b..5c8ae298dd 100644 --- a/delta/plugins/storage/src/test/resources/files/sse/file-created.json +++ b/delta/plugins/storage/src/test/resources/files/sse/file-created.json @@ -4,6 +4,7 @@ "https://bluebrain.github.io/nexus/contexts/files.json" ], "@type": "FileCreated", + "tag": null, "_attributes": { "_bytes": 12, "_digest": { diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala index b8c0c33f7b..a5fbdca3c0 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala @@ -2,7 +2,9 @@ package ch.epfl.bluebrain.nexus.delta.plugins.storage.files import akka.http.scaladsl.model.ContentTypes.`text/plain(UTF-8)` import akka.http.scaladsl.model.{HttpEntity, MessageEntity, Multipart, Uri} -import ch.epfl.bluebrain.nexus.delta.kernel.utils.{StatefulUUIDF, UUIDF, UrlUtils} +import cats.effect.concurrent.Ref +import cats.implicits.catsSyntaxApplicativeError +import ch.epfl.bluebrain.nexus.delta.kernel.utils.{UUIDF, UrlUtils} import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.Digest.ComputedDigest import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes.FileAttributesOrigin.Client @@ -11,46 +13,60 @@ import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv import ch.epfl.bluebrain.nexus.delta.sdk.generators.ProjectGen import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ApiMappings import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ -import ch.epfl.bluebrain.nexus.delta.sourcing.model.ResourceRef -import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ResourceRef} import ch.epfl.bluebrain.nexus.testkit.{EitherValuable, IOValues} +import monix.bio.Task import java.nio.file.{Files => JavaFiles} import java.util.UUID trait FileFixtures extends EitherValuable with IOValues { - val uuid = UUID.fromString("8249ba90-7cc6-4de5-93a1-802c04200dcc") - implicit val uuidF: StatefulUUIDF = UUIDF.stateful(uuid).accepted - val org = Label.unsafe("org") - val orgDeprecated = Label.unsafe("org-deprecated") - val project = ProjectGen.project("org", "proj", base = nxv.base, mappings = ApiMappings("file" -> schemas.files)) - val deprecatedProject = ProjectGen.project("org", "proj-deprecated") - val projectWithDeprecatedOrg = ProjectGen.project("org-deprecated", "other-proj") - val projectRef = project.ref - val diskId = nxv + "disk" - val diskRev = ResourceRef.Revision(iri"$diskId?rev=1", diskId, 1) - val diskId2 = nxv + "disk2" - val file1 = nxv + "file1" - val file2 = nxv + "file2" - val file1Encoded = UrlUtils.encode(file1.toString) - val generatedId = project.base.iri / uuid.toString + val uuid = UUID.fromString("8249ba90-7cc6-4de5-93a1-802c04200dcc") + val uuid2 = UUID.fromString("12345678-7cc6-4de5-93a1-802c04200dcc") + val ref = Ref.of[Task, UUID](uuid).accepted + implicit val uuidF: UUIDF = UUIDF.fromRef(ref) + val org = Label.unsafe("org") + val orgDeprecated = Label.unsafe("org-deprecated") + val project = ProjectGen.project("org", "proj", base = nxv.base, mappings = ApiMappings("file" -> schemas.files)) + val deprecatedProject = ProjectGen.project("org", "proj-deprecated") + val projectWithDeprecatedOrg = ProjectGen.project("org-deprecated", "other-proj") + val projectRef = project.ref + val diskId = nxv + "disk" + val diskRev = ResourceRef.Revision(iri"$diskId?rev=1", diskId, 1) + val diskId2 = nxv + "disk2" + val file1 = nxv + "file1" + val file2 = nxv + "file2" + val fileTagged = nxv + "fileTagged" + val fileTagged2 = nxv + "fileTagged2" + val file1Encoded = UrlUtils.encode(file1.toString) + val generatedId = project.base.iri / uuid.toString + val generatedId2 = project.base.iri / uuid2.toString val content = "file content" val path = AbsolutePath(JavaFiles.createTempDirectory("files")).rightValue val digest = ComputedDigest(DigestAlgorithm.default, "e0ac3601005dfa1864f5392aabaf7d898b1b5bab854f1acb4491bcd806b76b0c") - def attributes(filename: String = "file.txt", size: Long = 12): FileAttributes = FileAttributes( - uuid, - s"file://$path/org/proj/8/2/4/9/b/a/9/0/$filename", - Uri.Path(s"org/proj/8/2/4/9/b/a/9/0/$filename"), - filename, - Some(`text/plain(UTF-8)`), - size, - digest, - Client - ) + def withUUIDF[T](id: UUID)(test: => T): T = (for { + old <- ref.getAndSet(id) + t <- Task.delay(test).onError(_ => ref.set(old)) + _ <- ref.set(old) + } yield t).accepted + + def attributes(filename: String = "file.txt", size: Long = 12, id: UUID = uuid): FileAttributes = { + val uuidPathSegment = id.toString.take(8).mkString("/") + FileAttributes( + id, + s"file://$path/org/proj/$uuidPathSegment/$filename", + Uri.Path(s"org/proj/$uuidPathSegment/$filename"), + filename, + Some(`text/plain(UTF-8)`), + size, + digest, + Client + ) + } def entity(filename: String = "file.txt"): MessageEntity = Multipart diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala index 2acfa30993..f6d19da5de 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala @@ -114,7 +114,8 @@ class FilesSpec(docker: RemoteStorageDocker) StoragesConfig(eventLogConfig, pagination, cfg), ServiceAccount(User("nexus-sa", Label.unsafe("sa"))) ).accepted - lazy val files: Files = Files( + + lazy val files: Files = Files( fetchContext.mapRejection(FileRejection.ProjectContextRejection), aclCheck, storages, @@ -138,13 +139,37 @@ class FilesSpec(docker: RemoteStorageDocker) "succeed with the id passed" in { files - .create("file1", Some(diskId), projectRef, entity("myfile.txt")) + .create("file1", Some(diskId), projectRef, entity("myfile.txt"), None) .accepted shouldEqual FileGen.resourceFor(file1, projectRef, diskRev, attributes("myfile.txt"), createdBy = bob, updatedBy = bob) } + "succeed and tag with the id passed" in { + withUUIDF(uuid2) { + val file = files + .create("fileTagged", Some(diskId), projectRef, entity("fileTagged.txt"), Some(tag)) + .accepted + + val attr = attributes("fileTagged.txt", id = uuid2) + val expectedData = + FileGen.resourceFor( + fileTagged, + projectRef, + diskRev, + attr, + createdBy = bob, + updatedBy = bob, + tags = Tags(tag -> 1) + ) + + val fileByTag = files.fetch(IdSegmentRef("fileTagged", tag), projectRef).accepted + file shouldEqual expectedData + fileByTag.value.tags.tags should contain(tag) + } + } + "succeed with randomly generated id" in { - files.create(None, projectRef, entity("myfile2.txt")).accepted shouldEqual + files.create(None, projectRef, entity("myfile2.txt"), None).accepted shouldEqual FileGen.resourceFor( generatedId, projectRef, @@ -155,14 +180,38 @@ class FilesSpec(docker: RemoteStorageDocker) ) } + "succeed and tag with randomly generated id" in { + withUUIDF(uuid2) { + val file = files + .create(None, projectRef, entity("fileTagged2.txt"), Some(tag)) + .accepted + + val attr = attributes("fileTagged2.txt", id = uuid2) + val expectedData = + FileGen.resourceFor( + generatedId2, + projectRef, + diskRev, + attr, + createdBy = bob, + updatedBy = bob, + tags = Tags(tag -> 1) + ) + + val fileByTag = files.fetch(IdSegmentRef(generatedId2, tag), projectRef).accepted + file shouldEqual expectedData + fileByTag.value.tags.tags should contain(tag) + } + } + "reject if no write permissions" in { files - .create("file2", Some(remoteId), projectRef, entity()) + .create("file2", Some(remoteId), projectRef, entity(), None) .rejectedWith[AuthorizationFailed] } "reject if file id already exists" in { - files.create("file1", None, projectRef, entity()).rejected shouldEqual + files.create("file1", None, projectRef, entity(), None).rejected shouldEqual ResourceAlreadyExists(file1, projectRef) } @@ -170,29 +219,29 @@ class FilesSpec(docker: RemoteStorageDocker) "reject if the file exceeds max file size for the storage" in { files - .create("file-too-long", Some(remoteId), projectRef, randomEntity("large_file", 280))(aliceCaller) + .create("file-too-long", Some(remoteId), projectRef, randomEntity("large_file", 280), None)(aliceCaller) .rejected shouldEqual FileTooLarge(300L, None) } "reject if the file exceeds the remaining available space on the storage" in { files - .create("file-too-long", Some(diskId), projectRef, randomEntity("large_file", 250)) + .create("file-too-long", Some(diskId), projectRef, randomEntity("large_file", 250), None) .rejected shouldEqual FileTooLarge(300L, Some(220)) } "reject if storage does not exist" in { val storage = nxv + "other-storage" - files.create("file2", Some(storage), projectRef, entity()).rejected shouldEqual + files.create("file2", Some(storage), projectRef, entity(), None).rejected shouldEqual WrappedStorageRejection(StorageNotFound(storage, projectRef)) } "reject if project does not exist" in { val projectRef = ProjectRef(org, Label.unsafe("other")) - files.create(None, projectRef, entity()).rejectedWith[ProjectContextRejection] + files.create(None, projectRef, entity(), None).rejectedWith[ProjectContextRejection] } "reject if project is deprecated" in { - files.create(Some(diskId), deprecatedProject.ref, entity()).rejectedWith[ProjectContextRejection] + files.create(Some(diskId), deprecatedProject.ref, entity(), None).rejectedWith[ProjectContextRejection] } } @@ -200,31 +249,45 @@ class FilesSpec(docker: RemoteStorageDocker) "reject if no write permissions" in { files - .createLink("file2", Some(remoteId), projectRef, None, None, Uri.Path.Empty) + .createLink("file2", Some(remoteId), projectRef, None, None, Uri.Path.Empty, None) .rejectedWith[AuthorizationFailed] } - "succeed with the id passed" in { + "succeed and tag with the id passed" in { aclCheck.append(AclAddress.Root, bob -> Set(otherWrite)).accepted val path = Uri.Path("my/file-3.txt") val tempAttr = attributes("myfile.txt").copy(digest = NotComputedDigest) val attr = tempAttr.copy(location = s"file:///app/nexustest/nexus/${tempAttr.path}", origin = Storage, mediaType = None) - files - .createLink("file2", Some(remoteId), projectRef, Some("myfile.txt"), None, path) - .accepted shouldEqual - FileGen.resourceFor(file2, projectRef, remoteRev, attr, RemoteStorageType, createdBy = bob, updatedBy = bob) + val expected = FileGen.resourceFor( + file2, + projectRef, + remoteRev, + attr, + RemoteStorageType, + createdBy = bob, + updatedBy = bob, + tags = Tags(tag -> 1) + ) + + val result = files + .createLink("file2", Some(remoteId), projectRef, Some("myfile.txt"), None, path, Some(tag)) + .accepted + + val fileByTag = files.fetch(IdSegmentRef("file2", tag), projectRef).accepted + result shouldEqual expected + fileByTag.value.tags.tags should contain(tag) } "reject if no filename" in { files - .createLink("file3", Some(remoteId), projectRef, None, None, Uri.Path("a/b/")) + .createLink("file3", Some(remoteId), projectRef, None, None, Uri.Path("a/b/"), None) .rejectedWith[InvalidFileLink] } "reject if file id already exists" in { files - .createLink("file2", Some(remoteId), projectRef, None, None, Uri.Path.Empty) + .createLink("file2", Some(remoteId), projectRef, None, None, Uri.Path.Empty, None) .rejected shouldEqual ResourceAlreadyExists(file2, projectRef) } @@ -232,19 +295,19 @@ class FilesSpec(docker: RemoteStorageDocker) "reject if storage does not exist" in { val storage = nxv + "other-storage" files - .createLink("file3", Some(storage), projectRef, None, None, Uri.Path.Empty) + .createLink("file3", Some(storage), projectRef, None, None, Uri.Path.Empty, None) .rejected shouldEqual WrappedStorageRejection(StorageNotFound(storage, projectRef)) } "reject if project does not exist" in { val projectRef = ProjectRef(org, Label.unsafe("other")) - files.createLink(None, projectRef, None, None, Uri.Path.Empty).rejectedWith[ProjectContextRejection] + files.createLink(None, projectRef, None, None, Uri.Path.Empty, None).rejectedWith[ProjectContextRejection] } "reject if project is deprecated" in { files - .createLink(Some(remoteId), deprecatedProject.ref, None, None, Uri.Path.Empty) + .createLink(Some(remoteId), deprecatedProject.ref, None, None, Uri.Path.Empty, None) .rejectedWith[ProjectContextRejection] } } @@ -300,7 +363,8 @@ class FilesSpec(docker: RemoteStorageDocker) RemoteStorageType, rev = 2, createdBy = bob, - updatedBy = bob + updatedBy = bob, + tags = Tags(tag -> 1) ) } } @@ -323,7 +387,8 @@ class FilesSpec(docker: RemoteStorageDocker) RemoteStorageType, rev = 3, createdBy = bob, - updatedBy = bob + updatedBy = bob, + tags = Tags(tag -> 1) ) } diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala index 761b593f40..0ac4196771 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala @@ -61,10 +61,10 @@ class FilesStmSpec "evaluating an incoming command" should { "create a new event from a CreateFile command" in { - val createCmd = CreateFile(id, projectRef, storageRef, DiskStorageType, attributes, bob) + val createCmd = CreateFile(id, projectRef, storageRef, DiskStorageType, attributes, bob, Some(myTag)) evaluate(None, createCmd).accepted shouldEqual - FileCreated(id, projectRef, storageRef, DiskStorageType, attributes, 1, epoch, bob) + FileCreated(id, projectRef, storageRef, DiskStorageType, attributes, 1, epoch, bob, Some(myTag)) } "create a new event from a UpdateFile command" in { @@ -126,7 +126,7 @@ class FilesStmSpec "reject with ResourceAlreadyExists when file already exists" in { val current = FileGen.state(id, projectRef, storageRef, attributes) - evaluate(Some(current), CreateFile(id, projectRef, storageRef, DiskStorageType, attributes, bob)) + evaluate(Some(current), CreateFile(id, projectRef, storageRef, DiskStorageType, attributes, bob, None)) .rejectedWith[ResourceAlreadyExists] } @@ -179,7 +179,7 @@ class FilesStmSpec "producing next state" should { "from a new FileCreated event" in { - val event = FileCreated(id, projectRef, storageRef, DiskStorageType, attributes, 1, epoch, bob) + val event = FileCreated(id, projectRef, storageRef, DiskStorageType, attributes, 1, epoch, bob, None) val nextState = FileGen.state(id, projectRef, storageRef, attributes, createdBy = bob, updatedBy = bob) next(None, event).value shouldEqual nextState diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala index 5b36b85238..36104f3543 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala @@ -48,7 +48,8 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures { ) // format: off - private val created = FileCreated(fileId, projectRef, storageRef, DiskStorageType, attributes.copy(digest = NotComputedDigest), 1, instant, subject) + private val created = FileCreated(fileId, projectRef, storageRef, DiskStorageType, attributes.copy(digest = NotComputedDigest), 1, instant, subject, None) + private val createdTagged = created.copy(tag = Some(tag)) private val updated = FileUpdated(fileId, projectRef, storageRef, DiskStorageType, attributes, 2, instant, subject) private val updatedAttr = FileAttributesUpdated(fileId, projectRef, storageRef, DiskStorageType, Some(`text/plain(UTF-8)`), 12, digest, 3, instant, subject) private val tagged = FileTagAdded(fileId, projectRef, storageRef, DiskStorageType, targetRev = 1, tag, 4, instant, subject) @@ -73,6 +74,12 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures { Created, expected(created, Json.fromInt(1), Json.Null, Json.Null, Json.fromString("Client")) ), + ( + createdTagged, + loadEvents("files", "file-created-tagged.json"), + Created, + expected(createdTagged, Json.fromInt(1), Json.Null, Json.Null, Json.fromString("Client")) + ), ( updated, loadEvents("files", "file-updated.json"), diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala index 2d8cb6a800..c3830dbfce 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala @@ -29,13 +29,14 @@ import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesDummy import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{Caller, ServiceAccount} import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ -import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceUris} +import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegmentRef, ResourceUris} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.events import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContextDummy import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution import ch.epfl.bluebrain.nexus.delta.sdk.utils.{BaseRouteSpec, RouteFixtures} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject, User} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef} import ch.epfl.bluebrain.nexus.testkit._ import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap @@ -97,7 +98,8 @@ class FilesRoutesSpec private val storagesStatistics: StoragesStatistics = (_, _) => IO.pure { StorageStatEntry(0, 0) } - private val aclCheck = AclSimpleCheck().accepted + private val aclCheck = AclSimpleCheck().accepted + lazy val storages: Storages = Storages( fetchContext.mapRejection(StorageRejection.ProjectContextRejection), ResolverContextResolution(rcr), @@ -125,6 +127,7 @@ class FilesRoutesSpec private val diskIdRev = ResourceRef.Revision(dId, 1) private val s3IdRev = ResourceRef.Revision(s3Id, 2) + private val tag = UserTag.unsafe("mytag") private val varyHeader = RawHeader("Vary", "Accept,Accept-Encoding") @@ -162,6 +165,19 @@ class FilesRoutesSpec } } + "create and tag a file" in { + withUUIDF(uuid2) { + Post("/v1/files/org/proj?tag=mytag", entity()) ~> routes ~> check { + status shouldEqual StatusCodes.Created + val attr = attributes(id = uuid2) + val expected = fileMetadata(projectRef, generatedId2, attr, diskIdRev) + val fileByTag = files.fetch(IdSegmentRef(generatedId2, tag), projectRef).accepted + response.asJson shouldEqual expected + fileByTag.value.tags.tags should contain(tag) + } + } + } + "fail to create a file link using a storage that does not allow it" in { val payload = json"""{"filename": "my.txt", "path": "my/file.txt", "mediaType": "text/plain"}""" Put("/v1/files/org/proj/file1", payload.toEntity) ~> routes ~> check { @@ -188,6 +204,22 @@ class FilesRoutesSpec } } + "create and tag a file with an authenticated user and provided id" in { + withUUIDF(uuid2) { + Put( + "/v1/files/org/proj/fileTagged?storage=s3-storage&tag=mytag", + entity("fileTagged.txt") + ) ~> asAlice ~> routes ~> check { + status shouldEqual StatusCodes.Created + val attr = attributes("fileTagged.txt", id = uuid2) + val expected = fileMetadata(projectRef, fileTagged, attr, s3IdRev, createdBy = alice, updatedBy = alice) + val fileByTag = files.fetch(IdSegmentRef(generatedId2, tag), projectRef).accepted + response.asJson shouldEqual expected + fileByTag.value.tags.tags should contain(tag) + } + } + } + "reject the creation of a file which already exists" in { Put("/v1/files/org/proj/file1", entity()) ~> routes ~> check { status shouldEqual StatusCodes.Conflict diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Tags.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Tags.scala index 0b82303dec..5794b00363 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Tags.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Tags.scala @@ -23,6 +23,8 @@ object Tags { def apply(first: (UserTag, Int), values: (UserTag, Int)*): Tags = Tags(Map(first) ++ values) + def apply(maybeTag: Option[UserTag], rev: Int): Tags = maybeTag.fold(empty)(t => apply(t -> rev)) + implicit val tagsDecoder: Decoder[Tags] = Decoder.decodeMap[UserTag, Int].map(Tags(_)) implicit val tagsEncoder: Encoder.AsObject[Tags] = diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala index bd8be60d06..36610c3d1a 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala @@ -263,11 +263,8 @@ object Resources { private[delta] def next(state: Option[ResourceState], event: ResourceEvent): Option[ResourceState] = { // format: off - def created(e: ResourceCreated): Option[ResourceState] = { - val tags = e.tag.fold(Tags.empty)(t => Tags(t -> e.rev)) - Option.when(state.isEmpty){ - ResourceState(e.id, e.project, e.schemaProject, e.source, e.compacted, e.expanded, e.remoteContexts, e.rev, deprecated = false, e.schema, e.types, tags, e.instant, e.subject, e.instant, e.subject) - } + def created(e: ResourceCreated): Option[ResourceState] = Option.when(state.isEmpty){ + ResourceState(e.id, e.project, e.schemaProject, e.source, e.compacted, e.expanded, e.remoteContexts, e.rev, deprecated = false, e.schema, e.types, Tags(e.tag, e.rev), e.instant, e.subject, e.instant, e.subject) } def updated(e: ResourceUpdated): Option[ResourceState] = state.map {