Skip to content

Commit

Permalink
Allow custom metadata when linking a file (#4758)
Browse files Browse the repository at this point in the history
  • Loading branch information
olivergrabinski authored Feb 27, 2024
1 parent c167efe commit eabbea5
Show file tree
Hide file tree
Showing 24 changed files with 326 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@ import cats.syntax.all._
import ch.epfl.bluebrain.nexus.delta.kernel.error.NotARejection
import ch.epfl.bluebrain.nexus.delta.kernel.http.MediaTypeDetectorConfig
import ch.epfl.bluebrain.nexus.delta.kernel.utils.FileUtils
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.{FileTooLarge, InvalidCustomMetadata, InvalidMultipartFieldName, WrappedAkkaRejection}
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{FileCustomMetadata, FileRejection}
import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import io.circe.generic.semiauto.deriveDecoder
import io.circe.{parser, Decoder}
import io.circe.parser

import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
Expand Down Expand Up @@ -157,14 +156,6 @@ object FormDataExtractor {
part.entity.discardBytes().future.as(None)
}

private case class FileCustomMetadata(
name: Option[String],
description: Option[String],
keywords: Option[Map[Label, String]]
)
implicit private val fileUploadMetadataDecoder: Decoder[FileCustomMetadata] =
deriveDecoder[FileCustomMetadata]

private def extractMetadata(
part: Multipart.FormData.BodyPart
): Either[FileRejection, FileCustomMetadata] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,16 @@ trait LimitedFileAttributes {
object FileAttributes {

def from(description: FileDescription, storageMetadata: FileStorageMetadata): FileAttributes = {
val customMetadata = description.metadata.getOrElse(FileCustomMetadata.empty)
FileAttributes(
storageMetadata.uuid,
storageMetadata.location,
storageMetadata.path,
description.filename,
description.mediaType,
description.keywords,
description.description,
description.name,
customMetadata.keywords.getOrElse(Map.empty),
customMetadata.description,
customMetadata.name,
storageMetadata.bytes,
storageMetadata.digest,
storageMetadata.origin
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model

import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import io.circe.Decoder
import io.circe.generic.semiauto.deriveDecoder

/**
* Custom metadata for a file that can be specified by the user.
*/
case class FileCustomMetadata(
name: Option[String],
description: Option[String],
keywords: Option[Map[Label, String]]
)

object FileCustomMetadata {

implicit val fileUploadMetadataDecoder: Decoder[FileCustomMetadata] =
deriveDecoder[FileCustomMetadata]

val empty: FileCustomMetadata = FileCustomMetadata(None, None, None)

}
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model

import akka.http.scaladsl.model.ContentType
import cats.implicits.catsSyntaxOptionId
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.UploadedFileInformation
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label

case class FileDescription(
filename: String,
keywords: Map[Label, String],
mediaType: Option[ContentType],
description: Option[String],
name: Option[String]
metadata: Option[FileCustomMetadata]
)

object FileDescription {
def from(file: File): FileDescription = {
from(file.attributes)
}

def from(fileAttributes: FileAttributes): FileDescription = {
def from(fileAttributes: FileAttributes): FileDescription =
FileDescription(
fileAttributes.filename,
fileAttributes.keywords,
fileAttributes.mediaType,
fileAttributes.description,
fileAttributes.name
FileCustomMetadata(
fileAttributes.name,
fileAttributes.description,
Some(fileAttributes.keywords)
).some
)

def from(info: UploadedFileInformation): FileDescription =
FileDescription(
info.filename,
Some(info.suppliedContentType),
FileCustomMetadata(
info.name,
info.description,
Some(info.keywords)
).some
)
}

def from(info: UploadedFileInformation): FileDescription = {
FileDescription(info.filename, info.keywords, Some(info.suppliedContentType), info.description, info.name)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,19 @@ object FileRejection {
* the rejection which occurred with the storage
*/
final case class LinkRejection(id: Iri, storageId: Iri, rejection: StorageFileRejection)
extends FileRejection(s"File '$id' could not be linked using storage '$storageId'", Some(rejection.loggedDetails))
extends FileRejection(
s"File '$id' could not be linked using storage '$storageId'",
Some(rejection.loggedDetails)
)

/**
* Rejection returned when attempting to link a file without providing a filename or a path that ends with a
* filename.
*/
final case object InvalidFileLink
extends FileRejection(
s"Linking a file cannot be performed without a 'filename' or a 'path' that does not end with a filename."
)

final case class CopyRejection(
sourceProj: ProjectRef,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes

import akka.http.scaladsl.model.MediaTypes.`multipart/form-data`
import akka.http.scaladsl.model.StatusCodes.Created
import akka.http.scaladsl.model.Uri.Path
import akka.http.scaladsl.model.headers.Accept
Expand All @@ -9,8 +8,9 @@ import akka.http.scaladsl.server._
import cats.effect.IO
import cats.syntax.all._
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection._
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{File, FileDescription, FileId, FileRejection}
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model._
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.permissions.{read => Read, write => Write}
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.FilesRoutes.LinkFileRequest.{fileDescriptionFromRequest, linkFileDecoder}
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.FilesRoutes._
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.{schemas, FileResource, Files}
import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.ShowFileLocation
Expand All @@ -28,7 +28,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
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}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import io.circe.Decoder
import io.circe.generic.extras.Configuration
Expand Down Expand Up @@ -85,17 +84,20 @@ final class FilesRoutes(
operationName(s"$prefixSegment/files/{org}/{project}") {
concat(
// Link a file without id segment
entity(as[LinkFile]) { case LinkFile(path, description) =>
entity(as[LinkFileRequest]) { linkRequest =>
emit(
Created,
files
.createLink(storage, project, description, path, tag)
.index(mode)
fileDescriptionFromRequest(linkRequest)
.flatMap { desc =>
files
.createLink(storage, project, desc, linkRequest.path, tag)
.index(mode)
}
.attemptNarrow[FileRejection]
)
},
// Create a file without id segment
(contentType(`multipart/form-data`) & extractRequestEntity) { entity =>
extractRequestEntity { entity =>
emit(
Created,
files.create(storage, project, entity, tag).index(mode).attemptNarrow[FileRejection]
Expand All @@ -116,11 +118,21 @@ final class FilesRoutes(
case (rev, storage, tag) =>
concat(
// Update a Link
entity(as[LinkFile]) { case LinkFile(path, description) =>
entity(as[LinkFileRequest]) { linkRequest =>
emit(
files
.updateLink(fileId, storage, description, path, rev, tag)
.index(mode)
fileDescriptionFromRequest(linkRequest)
.flatMap { description =>
files
.updateLink(
fileId,
storage,
description,
linkRequest.path,
rev,
tag
)
.index(mode)
}
.attemptNarrow[FileRejection]
)
},
Expand All @@ -138,12 +150,15 @@ final class FilesRoutes(
parameters("storage".as[IdSegment].?, "tag".as[UserTag].?) { case (storage, tag) =>
concat(
// Link a file with id segment
entity(as[LinkFile]) { case LinkFile(path, description) =>
entity(as[LinkFileRequest]) { linkRequest =>
emit(
Created,
files
.createLink(fileId, storage, description, path, tag)
.index(mode)
fileDescriptionFromRequest(linkRequest)
.flatMap { description =>
files
.createLink(fileId, storage, description, linkRequest.path, tag)
.index(mode)
}
.attemptNarrow[FileRejection]
)
},
Expand Down Expand Up @@ -279,37 +294,18 @@ object FilesRoutes {
path: Path,
filename: Option[String],
mediaType: Option[ContentType],
keywords: Map[Label, String] = Map.empty,
description: Option[String],
name: Option[String]
metadata: Option[FileCustomMetadata]
)
final case class LinkFile(path: Path, fileDescription: FileDescription)
object LinkFile {

object LinkFileRequest {
@nowarn("cat=unused")
implicit private val config: Configuration = Configuration.default.withStrictDecoding.withDefaults
implicit val linkFileDecoder: Decoder[LinkFile] = {
deriveConfiguredDecoder[LinkFileRequest]
.flatMap { case LinkFileRequest(path, filename, mediaType, keywords, description, name) =>
filename.orElse(path.lastSegment) match {
case Some(derivedFilename) =>
Decoder.const(
LinkFile(
path,
FileDescription(
derivedFilename,
keywords,
mediaType,
description,
name
)
)
)
case None =>
Decoder.failedWithMessage(
"Linking a file cannot be performed without a 'filename' or a 'path' that does not end with a filename."
)
}
}
}
implicit private val config: Configuration = Configuration.default
implicit val linkFileDecoder: Decoder[LinkFileRequest] = deriveConfiguredDecoder[LinkFileRequest]

def fileDescriptionFromRequest(f: LinkFileRequest): IO[FileDescription] =
f.filename.orElse(f.path.lastSegment) match {
case Some(value) => IO.pure(FileDescription(value, f.mediaType, f.metadata))
case None => IO.raiseError(InvalidFileLink)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import akka.http.scaladsl.model.Uri
import akka.http.scaladsl.model.Uri.Path
import cats.data.NonEmptyList
import cats.effect.IO
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes
import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{FileAttributes, FileCustomMetadata}
import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.Storage.RemoteDiskStorage
import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.SaveFile.intermediateFolders
import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient
Expand Down Expand Up @@ -42,15 +42,16 @@ object RemoteDiskStorageCopyFiles {
): FileAttributes = {
val sourceFileMetadata = cd.sourceMetadata
val sourceFileDescription = cd.sourceUserSuppliedMetadata
val customMetadata = sourceFileDescription.metadata.getOrElse(FileCustomMetadata.empty)
FileAttributes(
uuid = cd.destUuid,
location = absoluteDestPath,
path = relativeDestPath,
filename = sourceFileDescription.filename,
mediaType = sourceFileDescription.mediaType,
keywords = sourceFileDescription.keywords,
description = sourceFileDescription.description,
name = sourceFileDescription.name,
keywords = customMetadata.keywords.getOrElse(Map.empty),
description = customMetadata.description,
name = customMetadata.name,
bytes = sourceFileMetadata.bytes,
digest = sourceFileMetadata.digest,
origin = sourceFileMetadata.origin
Expand Down
Loading

0 comments on commit eabbea5

Please sign in to comment.