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 ba856921ff..fdb8772ba6 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 @@ -222,6 +222,17 @@ final class Files( } yield res }.span("updateFile") + def updateMetadata( + id: FileId, + rev: Int, + metadata: FileCustomMetadata + )(implicit caller: Caller): IO[FileResource] = { + for { + (iri, _) <- id.expandIri(fetchContext.onModify) + res <- eval(UpdateFileCustomMetadata(iri, id.project, metadata, rev, caller.subject)) + } yield res + }.span("updateFileMetadata") + /** * Update a new file linking it from an existing file in a storage * @@ -570,6 +581,11 @@ object Files { def updatedAttributes(e: FileAttributesUpdated): Option[FileState] = state.map { s => s.copy(rev = e.rev, attributes = s.attributes.copy( mediaType = e.mediaType, bytes = e.bytes, digest = e.digest), updatedAt = e.instant, updatedBy = e.subject) } + + def updatedCustomMetadata(e: FileCustomMetadataUpdated): Option[FileState] = state.map { s => + val newAttributes = FileAttributes.setCustomMetadata(s.attributes, e.metadata) + s.copy(rev = e.rev, attributes = newAttributes, updatedAt = e.instant, updatedBy = e.subject) + } def tagAdded(e: FileTagAdded): Option[FileState] = state.map { s => s.copy(rev = e.rev, tags = s.tags + (e.tag -> e.targetRev), updatedAt = e.instant, updatedBy = e.subject) @@ -589,13 +605,14 @@ object Files { } event match { - case e: FileCreated => created(e) - case e: FileUpdated => updated(e) - case e: FileAttributesUpdated => updatedAttributes(e) - case e: FileTagAdded => tagAdded(e) - case e: FileTagDeleted => tagDeleted(e) - case e: FileDeprecated => deprecated(e) - case e: FileUndeprecated => undeprecated(e) + case e: FileCreated => created(e) + case e: FileUpdated => updated(e) + case e: FileAttributesUpdated => updatedAttributes(e) + case e: FileCustomMetadataUpdated => updatedCustomMetadata(e) + case e: FileTagAdded => tagAdded(e) + case e: FileTagDeleted => tagDeleted(e) + case e: FileDeprecated => deprecated(e) + case e: FileUndeprecated => undeprecated(e) } } @@ -633,6 +650,16 @@ object Files { // format: on } + def updateCustomMetadata(c: UpdateFileCustomMetadata) = state match { + case None => IO.raiseError(FileNotFound(c.id, c.project)) + case Some(s) if s.rev != c.rev => IO.raiseError(IncorrectRev(c.rev, s.rev)) + case Some(s) => + clock.realTimeInstant + .map( + FileCustomMetadataUpdated(c.id, c.project, s.storage, s.storageType, c.metadata, s.rev + 1, _, c.subject) + ) + } + def tag(c: TagFile) = state match { case None => IO.raiseError(FileNotFound(c.id, c.project)) case Some(s) if s.rev != c.rev => IO.raiseError(IncorrectRev(c.rev, s.rev)) @@ -671,13 +698,14 @@ object Files { } cmd match { - case c: CreateFile => create(c) - case c: UpdateFile => update(c) - case c: UpdateFileAttributes => updateAttributes(c) - case c: TagFile => tag(c) - case c: DeleteFileTag => deleteTag(c) - case c: DeprecateFile => deprecate(c) - case c: UndeprecateFile => undeprecate(c) + case c: CreateFile => create(c) + case c: UpdateFile => update(c) + case c: UpdateFileAttributes => updateAttributes(c) + case c: UpdateFileCustomMetadata => updateCustomMetadata(c) + case c: TagFile => tag(c) + case c: DeleteFileTag => deleteTag(c) + case c: DeprecateFile => deprecate(c) + case c: UndeprecateFile => undeprecate(c) } } diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileAttributes.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileAttributes.scala index fbe8f57f59..f25db277c4 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileAttributes.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileAttributes.scala @@ -79,6 +79,14 @@ object FileAttributes { ) } + /** Set the metadata of the provided [[FileAttributes]] to the metadata provided in [[FileCustomMetadata]] */ + def setCustomMetadata(attr: FileAttributes, newCustomMetadata: FileCustomMetadata): FileAttributes = + attr.copy( + keywords = newCustomMetadata.keywords.getOrElse(Map.empty), + description = newCustomMetadata.description, + name = newCustomMetadata.name + ) + /** * Enumeration of all possible inputs that generated the file attributes */ 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 441c2d5dfe..e5f9240aba 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 @@ -98,6 +98,28 @@ object FileCommand { tag: Option[UserTag] ) extends FileCommand + /** + * Command to update the custom metadata of a file + * + * @param id + * the file identifier + * @param project + * the project the file belongs to + * @param metadata + * the custom metadata to update + * @param rev + * the last known revision of the file + * @param subject + * the identity associated to this command + */ + final case class UpdateFileCustomMetadata( + id: Iri, + project: ProjectRef, + metadata: FileCustomMetadata, + rev: Int, + subject: Subject + ) extends FileCommand + /** * Command to update an asynchronously computed file attributes. This command gets issued when linking a file using a * ''RemoteDiskStorage''. Since the attributes cannot be computed synchronously, ''NotComputedDigest'' and wrong size 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 0377d1ae05..c4717ed878 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 @@ -3,15 +3,15 @@ package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model import akka.http.scaladsl.model.ContentType import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes.FileAttributesOrigin import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.{contexts, nxvFile, Files} +import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.ShowFileLocation import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.StorageType import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords -import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.ShowFileLocation import ch.epfl.bluebrain.nexus.delta.sdk.circe.JsonObjOps +import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.IriEncoder import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.model.metrics.EventMetric._ @@ -130,6 +130,37 @@ object FileEvent { tag: Option[UserTag] ) extends FileEvent + /** + * Event for the modification of the custom metadata of a file + * + * @param id + * the file identifier + * @param project + * the project the file belongs to + * @param storage + * the reference to the remote storage used + * @param storageType + * the type of storage + * @param metadata + * the new custom metadata + * @param rev + * the last known revision of the file + * @param instant + * the instant this event was created + * @param subject + * the subject which created this event + */ + final case class FileCustomMetadataUpdated( + id: Iri, + project: ProjectRef, + storage: ResourceRef.Revision, + storageType: StorageType, + metadata: FileCustomMetadata, + rev: Int, + instant: Instant, + subject: Subject + ) extends FileEvent + /** * Event for the modification of an asynchronously computed file attributes. This event gets recorded when linking a * file using a ''RemoteDiskStorage''. Since the attributes cannot be computed synchronously, ''NotComputedDigest'' @@ -322,13 +353,14 @@ object FileEvent { ProjectScopedMetric.from( event, event match { - case _: FileCreated => Created - case _: FileUpdated => Updated - case _: FileAttributesUpdated => Updated - case _: FileTagAdded => Tagged - case _: FileTagDeleted => TagDeleted - case _: FileDeprecated => Deprecated - case _: FileUndeprecated => Undeprecated + case _: FileCreated => Created + case _: FileUpdated => Updated + case _: FileAttributesUpdated => Updated + case _: FileCustomMetadataUpdated => Updated + case _: FileTagAdded => Tagged + case _: FileTagDeleted => TagDeleted + case _: FileDeprecated => Deprecated + case _: FileUndeprecated => Undeprecated }, event.id, Set(nxvFile), @@ -466,6 +498,8 @@ object FileEvent { fau.mediaType, Some(FileAttributesOrigin.Storage) ) + case fcmu: FileCustomMetadataUpdated => + FileExtraFields(fcmu.storage.iri, fcmu.storageType, None, None, None, None) case fta: FileTagAdded => FileExtraFields(fta.storage.iri, fta.storageType, None, None, None, None) case ftd: FileTagDeleted => diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala index 53938e818d..20a37d5a40 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala @@ -141,12 +141,7 @@ object FileRejection { final case class InvalidMultipartFieldName(id: Iri) extends FileRejection(s"File '$id' payload a Multipart/Form-Data without a 'file' part.") - /** - * Rejection returned when attempting to create/update a file with a Multipart/Form-Data payload that contains - * invalid metadata - */ - final case class InvalidCustomMetadata(err: String) - extends FileRejection(s"File payload contained metadata which could not be parsed: $err") + final case object EmptyCustomMetadata extends FileRejection(s"No metadata was provided") /** * Rejection returned when attempting to create/update a file with a Multipart/Form-Data payload that does not 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 714f2f8213..16f03cddba 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 @@ -138,13 +138,29 @@ final class FilesRoutes( ) }, // Update a file - (extractRequestEntity & extractFileMetadata) { (entity, metadata) => - emit( - files - .update(fileId, storage, rev, entity, tag, metadata) - .index(mode) - .attemptNarrow[FileRejection] - ) + (requestEntityPresent & extractRequestEntity & extractFileMetadata) { + (entity, metadata) => + emit( + files + .update(fileId, storage, rev, entity, tag, metadata) + .index(mode) + .attemptNarrow[FileRejection] + ) + }, + // Update custom metadata + (requestEntityEmpty & extractFileMetadata & authorizeFor(project, Write)) { + case Some(FileCustomMetadata.empty) => + emit( + IO.raiseError[FileResource](EmptyCustomMetadata).attemptNarrow[FileRejection] + ) + case Some(metadata) => + emit( + files + .updateMetadata(fileId, rev, metadata) + .index(mode) + .attemptNarrow[FileRejection] + ) + case None => reject } ) }, diff --git a/delta/plugins/storage/src/test/resources/files/database/file-custom-metadata-updated.json b/delta/plugins/storage/src/test/resources/files/database/file-custom-metadata-updated.json new file mode 100644 index 0000000000..88c83c924b --- /dev/null +++ b/delta/plugins/storage/src/test/resources/files/database/file-custom-metadata-updated.json @@ -0,0 +1,21 @@ +{ + "id": "https://bluebrain.github.io/nexus/vocabulary/file", + "project": "myorg/myproj", + "storage": "https://bluebrain.github.io/nexus/vocabulary/disk-storage?rev=1", + "storageType": "DiskStorage", + "metadata": { + "name": "A name", + "description": "A description", + "keywords": { + "key": "value" + } + }, + "rev": 3, + "instant": "1970-01-01T00:00:00Z", + "subject": { + "subject": "username", + "realm": "myrealm", + "@type": "User" + }, + "@type": "FileCustomMetadataUpdated" +} diff --git a/delta/plugins/storage/src/test/resources/files/sse/file-custom-metadata-updated.json b/delta/plugins/storage/src/test/resources/files/sse/file-custom-metadata-updated.json new file mode 100644 index 0000000000..3ff2d60f75 --- /dev/null +++ b/delta/plugins/storage/src/test/resources/files/sse/file-custom-metadata-updated.json @@ -0,0 +1,20 @@ +{ + "@context": [ + "https://bluebrain.github.io/nexus/contexts/metadata.json", + "https://bluebrain.github.io/nexus/contexts/files.json" + ], + "@type": "FileCustomMetadataUpdated", + "metadata": { + "name": "A name", + "description": "A description", + "keywords": { + "key": "value" + } + }, + "_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": 3, + "_subject": "http://localhost/v1/realms/myrealm/users/username" +} \ No newline at end of file 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 f4cab71903..3bfaab0651 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 @@ -399,6 +399,58 @@ class FilesSpec(fixture: RemoteStorageClientFixtures) } } + "updating the custom metadata of a file" should { + + "succeed" in { + val id = fileId(genString()) + val metadata = genCustomMetadata() + val file = entity(genString()) + + files.create(id, Some(diskId), file, None, None).accepted + files.updateMetadata(id, 1, metadata).accepted + + files.fetch(id).accepted.rev shouldEqual 2 + assertCorrectCustomMetadata(id, metadata) + } + + "reject if the wrong revision is specified" in { + val id = fileId(genString()) + val metadata = genCustomMetadata() + val file = entity(genString()) + + files.create(id, Some(diskId), file, None, None).accepted + files + .updateMetadata(id, 2, metadata) + .rejected shouldEqual IncorrectRev(expected = 1, provided = 2) + } + + "reject if file doesn't exists" in { + val nonExistentFile = fileIdIri(nxv + genString()) + + files + .updateMetadata(nonExistentFile, 1, genCustomMetadata()) + .rejectedWith[FileNotFound] + } + + "reject if project does not exist" in { + val nonexistentProject = ProjectRef(org, Label.unsafe(genString())) + val fileInNonexistentProject = FileId(genString(), nonexistentProject) + + files + .updateMetadata(fileInNonexistentProject, 1, genCustomMetadata()) + .rejectedWith[ProjectNotFound] + } + + "reject if project is deprecated" in { + val fileInDeprecatedProject = FileId(genString(), deprecatedProject.ref) + + files + .updateMetadata(fileInDeprecatedProject, 1, genCustomMetadata()) + .rejectedWith[ProjectIsDeprecated] + } + + } + "updating remote disk file attributes" should { "reject if digest is already computed" in { 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 874479635d..ebd0f9518d 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 @@ -55,6 +55,8 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures { private val attributesWithMetadata = attributes.copy(keywords = keywords, description = Some(description), name = Some(name)) + private val customMetadata = + FileCustomMetadata(Some(name), Some(description), Some(keywords)) // format: off private val created = FileCreated(fileId, projectRef, storageRef, DiskStorageType, attributes.copy(digest = NotComputedDigest), 1, instant, subject, None) @@ -63,6 +65,7 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures { private val createdTaggedWithMetadata = createdWithMetadata.copy(tag = Some(tag)) private val updated = FileUpdated(fileId, projectRef, storageRef, DiskStorageType, attributes, 2, instant, subject, Some(tag)) private val updatedAttr = FileAttributesUpdated(fileId, projectRef, storageRef, DiskStorageType, Some(`text/plain(UTF-8)`), 12, digest, 3, instant, subject) + private val updatedMetadata = FileCustomMetadataUpdated(fileId, projectRef, storageRef, DiskStorageType, customMetadata, 3, instant, subject) private val tagged = FileTagAdded(fileId, projectRef, storageRef, DiskStorageType, targetRev = 1, tag, 4, instant, subject) private val tagDeleted = FileTagDeleted(fileId, projectRef, storageRef, DiskStorageType, tag, 4, instant, subject) private val deprecated = FileDeprecated(fileId, projectRef, storageRef, DiskStorageType, 5, instant, subject) @@ -134,6 +137,19 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures { Json.fromString("Storage") ) ), + ( + "FileCustomMetadataUpdated", + updatedMetadata, + loadEvents("files", "file-custom-metadata-updated.json"), + Updated, + expected( + updatedMetadata, + Json.Null, + Json.Null, + Json.Null, + Json.Null + ) + ), ( "FileTagAdded", tagged, 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 1a230feee0..f62ca42ae9 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 @@ -385,7 +385,7 @@ class FilesRoutesSpec } } - "fail to update a file with invalid metadata" in { + "fail to update a file with invalid custom metadata" in { givenAFile { id => val invalidKey = Label.unsafe("!@#$%^&") val invalidMetadata = genCustomMetadata().copy(keywords = Some(Map(invalidKey -> "value"))) @@ -409,6 +409,68 @@ class FilesRoutesSpec } } + "fail to update custom metadata without permission" in { + givenAFile { id => + val metadata = genCustomMetadata() + val headers = RawHeader("x-nxs-file-metadata", metadata.asJson.noSpaces) + Put(s"/v1/files/org/proj/$id?rev=1").withHeaders(headers) ~> routes ~> check { + response.shouldBeForbidden + } + } + } + + "update only custom metadata with no entity provided" in { + givenAFile { id => + val metadata = genCustomMetadata() + val kw = metadata.keywords.get.map { case (k, v) => k.toString -> v } + + val headers = RawHeader("x-nxs-file-metadata", metadata.asJson.noSpaces) + Put(s"/v1/files/org/proj/$id?rev=1").withHeaders(headers) ~> asWriter ~> routes ~> check { + status shouldEqual StatusCodes.OK + response.asJson should have(description(metadata.description.get)) + response.asJson should have(name(metadata.name.get)) + response.asJson should have(keywords(kw)) + } + } + } + + "return an error when attempting to update custom metadata without providing it" in { + givenAFile { id => + Put(s"/v1/files/org/proj/$id?rev=1") ~> asWriter ~> routes ~> check { + status shouldEqual StatusCodes.BadRequest + response.asJson shouldEqual + json""" + { + "@context" : "https://bluebrain.github.io/nexus/contexts/error.json", + "@type" : "EmptyCustomMetadata", + "reason" : "No metadata was provided" + } + """ + } + } + } + + "return an error when attempting to update with invalid custom metadata" in { + givenAFile { id => + val invalidKey = Label.unsafe("!@#$%^&") + val invalidMetadata = genCustomMetadata().copy(keywords = Some(Map(invalidKey -> "value"))) + + val headers = RawHeader("x-nxs-file-metadata", invalidMetadata.asJson.noSpaces) + Put(s"/v1/files/org/proj/$id?rev=1").withHeaders(headers) ~> asWriter ~> routes ~> check { + status shouldEqual StatusCodes.BadRequest + response.asJson shouldEqual + json""" + { + "@context" : "https://bluebrain.github.io/nexus/contexts/error.json", + "@type" : "MalformedHeaderRejection", + "reason" : "The value of HTTP header 'x-nxs-file-metadata' was malformed.", + "details" : "DecodingFailure at .keywords.$invalidKey: Couldn't decode key." + } + """ + } + } + } + "update and tag a file in one request" in { givenAFile { id => putFile(s"/v1/files/org/proj/$id?rev=1&tag=mytag", entity(s"$id.txt")) ~> asWriter ~> routes ~> check { diff --git a/docs/src/main/paradox/docs/delta/api/files-api.md b/docs/src/main/paradox/docs/delta/api/files-api.md index f60b5b1b8c..be84fe0ffb 100644 --- a/docs/src/main/paradox/docs/delta/api/files-api.md +++ b/docs/src/main/paradox/docs/delta/api/files-api.md @@ -206,6 +206,12 @@ PUT /v1/files/{org_label}/{project_label}/{resource_id}?rev={previous_rev} - `description`: a string that describes the file. It will be indexed in the full-text search. - `keywords`: a JSON object with `Label` keys and `string` values. These keywords will be indexed and can be used to search for the file. +@@@ note { .tip title="Metadata update" } + +If only the metadata is provided, then the updated is a metadata update and the file content is not changed. + +@@@ + **Example** Request diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/files/FilesSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/files/FilesSpec.scala index 879a556739..8196b5eca3 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/files/FilesSpec.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/files/FilesSpec.scala @@ -1,11 +1,13 @@ package ch.epfl.bluebrain.nexus.tests.kg.files +import akka.http.scaladsl.model.headers.RawHeader import akka.http.scaladsl.model.{ContentTypes, StatusCodes} import cats.effect.IO import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils import ch.epfl.bluebrain.nexus.testkit.scalatest.FileMatchers.{description => descriptionField, keywords, name => nameField} import ch.epfl.bluebrain.nexus.testkit.scalatest.ResourceMatchers.`@id` import ch.epfl.bluebrain.nexus.tests.BaseIntegrationSpec +import ch.epfl.bluebrain.nexus.tests.Identity.Anonymous import ch.epfl.bluebrain.nexus.tests.Identity.files.Writer import ch.epfl.bluebrain.nexus.tests.Optics.listing._total import io.circe.Json @@ -96,6 +98,52 @@ class FilesSpec extends BaseIntegrationSpec { } } + "Updating only custom metadata" should { + + "fail without permission" in { + givenAFile { id => + val md = Json.obj( + "name" -> Json.fromString("new name") + ) + val header = RawHeader("x-nxs-file-metadata", md.noSpaces) :: Nil + + val rejectCustomMetadataUpdate = deltaClient + .putEmptyBody[Json](s"/files/$projectRef/$id?rev=1", Anonymous, header) { (_, response) => + response.status shouldEqual StatusCodes.Forbidden + } + val assertFileNotUpdated = deltaClient.get[Json](s"/files/$projectRef/$id", Writer) { (json, response) => + response.status shouldEqual StatusCodes.OK + json.hcursor.get[Int]("_rev").rightValue shouldEqual 1 + } + + (rejectCustomMetadataUpdate >> assertFileNotUpdated).accepted + + } + } + + "update the custom metadata of the file" in { + givenAFile { id => + val updatedName = "new name" + val md = Json.obj("name" := updatedName) + val header = RawHeader("x-nxs-file-metadata", md.noSpaces) :: Nil + + val updateCustomMetadata = deltaClient + .putEmptyBody[Json](s"/files/$projectRef/$id?rev=1", Writer, header) { (_, response) => + response.status shouldEqual StatusCodes.OK + } + + val assertMetadataUpdated = deltaClient.get[Json](s"/files/$projectRef/$id", Writer) { (json, response) => + response.status shouldEqual StatusCodes.OK + json.hcursor.get[Int]("_rev").rightValue shouldEqual 2 + json should have(nameField(updatedName)) + } + + (updateCustomMetadata >> assertMetadataUpdated).accepted + } + + } + } + private def assertListingTotal(id: String, expectedTotal: Int) = deltaClient.get[Json](s"/files/$projectRef?locate=$id&deprecated=false", Writer) { (json, response) => response.status shouldEqual StatusCodes.OK