From 52443fe747a50c02b8e0f5c03576772cea49a986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vandendaelen?= Date: Tue, 23 Jul 2024 21:49:25 +0200 Subject: [PATCH] feat: Added support for multipart/form-data file upload. Closes #1 --- ...ptions.kt => Base64FileCreationOptions.kt} | 2 +- .../vandeas/dto/BytesFileCreationOptions.kt | 10 ++++ src/main/kotlin/be/vandeas/logic/FileLogic.kt | 8 ++- .../be/vandeas/logic/impl/FileLogicImpl.kt | 14 +++-- src/main/kotlin/be/vandeas/plugins/Routing.kt | 51 +++++++++++++++++-- .../kotlin/be/vandeas/service/FileService.kt | 8 ++- .../vandeas/service/impl/FileServiceImpl.kt | 13 +++-- src/test/kotlin/be/vandeas/ApplicationTest.kt | 47 +++++++++++++++-- 8 files changed, 125 insertions(+), 28 deletions(-) rename src/main/kotlin/be/vandeas/dto/{FileCreationOptions.kt => Base64FileCreationOptions.kt} (80%) create mode 100644 src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt diff --git a/src/main/kotlin/be/vandeas/dto/FileCreationOptions.kt b/src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt similarity index 80% rename from src/main/kotlin/be/vandeas/dto/FileCreationOptions.kt rename to src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt index 500c689..428ef79 100644 --- a/src/main/kotlin/be/vandeas/dto/FileCreationOptions.kt +++ b/src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt @@ -3,7 +3,7 @@ package be.vandeas.dto import kotlinx.serialization.Serializable @Serializable -data class FileCreationOptions( +data class Base64FileCreationOptions( val path: String, val fileName: String, val content: String, diff --git a/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt b/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt new file mode 100644 index 0000000..639fdfb --- /dev/null +++ b/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt @@ -0,0 +1,10 @@ +package be.vandeas.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class BytesFileCreationOptions( + val path: String, + val fileName: String, + val content: ByteArray, +) diff --git a/src/main/kotlin/be/vandeas/logic/FileLogic.kt b/src/main/kotlin/be/vandeas/logic/FileLogic.kt index 4ab6bd0..8321c03 100644 --- a/src/main/kotlin/be/vandeas/logic/FileLogic.kt +++ b/src/main/kotlin/be/vandeas/logic/FileLogic.kt @@ -1,13 +1,11 @@ package be.vandeas.logic import be.vandeas.domain.* -import be.vandeas.dto.DirectoryDeleteOptions -import be.vandeas.dto.FileCreationOptions -import be.vandeas.dto.FileDeleteOptions -import be.vandeas.dto.FileReadOptions +import be.vandeas.dto.* interface FileLogic { - fun createFile(options: FileCreationOptions): FileCreationResult + fun createFile(options: Base64FileCreationOptions): FileCreationResult + fun createFile(options: BytesFileCreationOptions): FileCreationResult fun deleteFile(fileDeleteOptions: FileDeleteOptions): FileDeleteResult fun deleteDirectory(directoryDeleteOptions: DirectoryDeleteOptions): DirectoryDeleteResult fun readFile(fileReadOptions: FileReadOptions): FileBytesReadResult diff --git a/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt b/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt index f0e1d7a..9834135 100644 --- a/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt +++ b/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt @@ -1,23 +1,27 @@ package be.vandeas.logic.impl import be.vandeas.domain.* -import be.vandeas.dto.DirectoryDeleteOptions -import be.vandeas.dto.FileCreationOptions -import be.vandeas.dto.FileDeleteOptions -import be.vandeas.dto.FileReadOptions +import be.vandeas.dto.* import be.vandeas.handler.FileHandler import be.vandeas.logic.FileLogic import io.ktor.util.* import java.nio.file.Paths class FileLogicImpl : FileLogic { - override fun createFile(options: FileCreationOptions): FileCreationResult { + override fun createFile(options: Base64FileCreationOptions): FileCreationResult { return FileHandler.write( content = options.content.decodeBase64Bytes(), filePath = Paths.get(options.path, options.fileName) ) } + override fun createFile(options: BytesFileCreationOptions): FileCreationResult { + return FileHandler.write( + content = options.content, + filePath = Paths.get(options.path, options.fileName) + ) + } + override fun deleteFile(fileDeleteOptions: FileDeleteOptions): FileDeleteResult { return FileHandler.deleteFile( path = Paths.get(fileDeleteOptions.path, fileDeleteOptions.fileName) diff --git a/src/main/kotlin/be/vandeas/plugins/Routing.kt b/src/main/kotlin/be/vandeas/plugins/Routing.kt index fcd8ac2..8bd3be0 100644 --- a/src/main/kotlin/be/vandeas/plugins/Routing.kt +++ b/src/main/kotlin/be/vandeas/plugins/Routing.kt @@ -3,9 +3,11 @@ package be.vandeas.plugins import be.vandeas.domain.* import be.vandeas.dto.* import be.vandeas.dto.ReadFileBytesResult.Companion.mapToReadFileBytesDto +import be.vandeas.exception.AuthorizationException import be.vandeas.logic.AuthLogic import be.vandeas.service.FileService import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.server.application.* import io.ktor.server.plugins.autohead.* import io.ktor.server.plugins.partialcontent.* @@ -27,7 +29,7 @@ fun Application.configureRouting() { get { val path = call.request.queryParameters["path"] ?: "" val fileName = call.request.queryParameters["fileName"] ?: "" - val authorization = call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") + val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") val accept = call.request.accept()?.let { ContentType.parse(it) } ?: ContentType.Application.Json if (path.isBlank() || fileName.isBlank()) { @@ -85,8 +87,8 @@ fun Application.configureRouting() { } post { - val options: FileCreationOptions = call.receive() - val authorization = call.request.authorization() ?: throw IllegalArgumentException("Authorization header is required") + val options: Base64FileCreationOptions = call.receive() + val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") when (val result = fileService.createFile(authorization, options)) { is FileCreationResult.Duplicate -> call.respond(HttpStatusCode.Conflict, FileNameWithPath(path = options.path, fileName = options.fileName)) @@ -96,6 +98,49 @@ fun Application.configureRouting() { } } + post("/upload") { + val authorization = call.request.authorization() ?: throw AuthorizationException("Authorization header is required") + val multipart = call.receiveMultipart() + + var fileName: String? = null + var path: String? = null + var data: ByteArray? = null + + multipart.forEachPart { part -> + when (part) { + is PartData.FormItem -> { + when (part.name) { + "path" -> path = part.value + "fileName" -> fileName = part.value + } + } + is PartData.FileItem -> { + data = part.streamProvider().readBytes() + } + else -> throw IllegalArgumentException("Unsupported part type: ${part::class.simpleName}") + } + part.dispose() + } + + requireNotNull(fileName) { "fileName is required" } + requireNotNull(path) { "path is required" } + requireNotNull(data) { "data is required" } + + val options = BytesFileCreationOptions( + path = path!!, + fileName = fileName!!, + content = data!! + ) + + when (val result = fileService.createFile(authorization, options)) { + is FileCreationResult.Duplicate -> call.respond(HttpStatusCode.Conflict, FileNameWithPath(path = options.path, fileName = options.fileName)) + is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) + is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) + is FileCreationResult.Success -> call.respond(HttpStatusCode.Created, FileNameWithPath(path = options.path, fileName = options.fileName)) + } + + } + delete { val path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path query parameter is required") val fileName = call.request.queryParameters["fileName"] diff --git a/src/main/kotlin/be/vandeas/service/FileService.kt b/src/main/kotlin/be/vandeas/service/FileService.kt index 77807e3..7cc903e 100644 --- a/src/main/kotlin/be/vandeas/service/FileService.kt +++ b/src/main/kotlin/be/vandeas/service/FileService.kt @@ -1,13 +1,11 @@ package be.vandeas.service import be.vandeas.domain.* -import be.vandeas.dto.DirectoryDeleteOptions -import be.vandeas.dto.FileCreationOptions -import be.vandeas.dto.FileDeleteOptions -import be.vandeas.dto.FileReadOptions +import be.vandeas.dto.* interface FileService { - fun createFile(token: String, options: FileCreationOptions): FileCreationResult + fun createFile(token: String, options: Base64FileCreationOptions): FileCreationResult + fun createFile(token: String, options: BytesFileCreationOptions): FileCreationResult fun deleteFile(token: String, fileDeleteOptions: FileDeleteOptions): FileDeleteResult fun deleteDirectory(token: String, directoryDeleteOptions: DirectoryDeleteOptions): DirectoryDeleteResult fun readFile(token: String, fileReadOptions: FileReadOptions): FileBytesReadResult diff --git a/src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt b/src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt index 79b97e1..9bcf45f 100644 --- a/src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt +++ b/src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt @@ -1,10 +1,7 @@ package be.vandeas.service.impl import be.vandeas.domain.* -import be.vandeas.dto.DirectoryDeleteOptions -import be.vandeas.dto.FileCreationOptions -import be.vandeas.dto.FileDeleteOptions -import be.vandeas.dto.FileReadOptions +import be.vandeas.dto.* import be.vandeas.logic.AuthLogic import be.vandeas.logic.FileLogic import be.vandeas.service.FileService @@ -14,11 +11,17 @@ class FileServiceImpl( private val fileLogic: FileLogic, private val authLogic: AuthLogic ) : FileService { - override fun createFile(token: String, options: FileCreationOptions): FileCreationResult = + override fun createFile(token: String, options: Base64FileCreationOptions): FileCreationResult = authLogic.guard(token, Path.of(options.path, options.fileName)) { fileLogic.createFile(options) } + override fun createFile(token: String, options: BytesFileCreationOptions): FileCreationResult { + return authLogic.guard(token, Path.of(options.path, options.fileName)) { + fileLogic.createFile(options) + } + } + override fun deleteFile(token: String, fileDeleteOptions: FileDeleteOptions): FileDeleteResult = authLogic.guard(token, Path.of(fileDeleteOptions.path, fileDeleteOptions.fileName)) { fileLogic.deleteFile(fileDeleteOptions) diff --git a/src/test/kotlin/be/vandeas/ApplicationTest.kt b/src/test/kotlin/be/vandeas/ApplicationTest.kt index 48f7022..5a06a43 100644 --- a/src/test/kotlin/be/vandeas/ApplicationTest.kt +++ b/src/test/kotlin/be/vandeas/ApplicationTest.kt @@ -1,11 +1,12 @@ package be.vandeas -import be.vandeas.dto.FileCreationOptions +import be.vandeas.dto.Base64FileCreationOptions import be.vandeas.dto.ReadFileBytesResult import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.request.* +import io.ktor.client.request.forms.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.util.* @@ -52,7 +53,7 @@ class ApplicationTest { accept(ContentType.Application.Json) header("Authorization", getToken(dirName, fileName)!!) setBody( - FileCreationOptions( + Base64FileCreationOptions( path = dirName, fileName = fileName, content = testedFile.readBytes().encodeBase64() @@ -88,7 +89,7 @@ class ApplicationTest { contentType(ContentType.Application.Json) header("Authorization", token!!) setBody( - FileCreationOptions( + Base64FileCreationOptions( path = dirName, fileName = fileName, content = testedFile.readBytes().encodeBase64() @@ -118,7 +119,7 @@ class ApplicationTest { contentType(ContentType.Application.Json) header("Authorization", getToken(dirName, fileName)!!) setBody( - FileCreationOptions( + Base64FileCreationOptions( path = dirName, fileName = fileName, content = testedFile.readBytes().encodeBase64() @@ -143,4 +144,42 @@ class ApplicationTest { } } + @Test + fun `Should be able to upload in multipart-form data`() { + runBlocking { + val fileNames = mapOf( + "file.txt" to ContentType.Text.Plain, + "file.pdf" to ContentType.Application.Pdf, + "img.webp" to ContentType.Image.Any + ) + + val dirName = "multipart-${UUID.randomUUID()}" + + fileNames.forEach { (fileName, contentType) -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + client.submitFormWithBinaryData("http://localhost:8082/v1/file/upload", formData { + append(key = "path", value = dirName) + append(key = "fileName", value = fileName) + append(key = "content", value = testedFile.readBytes(), headers = Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + }) { + header("Authorization", getToken(dirName, fileName)!!) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + client.get("http://localhost:8082/v1/file?path=$dirName&fileName=$fileName") { + header("Authorization", getToken(dirName, fileName)!!) + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) + } + } + } + } + }