Skip to content

Commit

Permalink
feat: Added support for multipart/form-data file upload. Closes #1
Browse files Browse the repository at this point in the history
  • Loading branch information
LotuxPunk committed Jul 23, 2024
1 parent 251fa54 commit 52443fe
Show file tree
Hide file tree
Showing 8 changed files with 125 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 3 additions & 5 deletions src/main/kotlin/be/vandeas/logic/FileLogic.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 9 additions & 5 deletions src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
51 changes: 48 additions & 3 deletions src/main/kotlin/be/vandeas/plugins/Routing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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()) {
Expand Down Expand Up @@ -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))
Expand All @@ -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"]
Expand Down
8 changes: 3 additions & 5 deletions src/main/kotlin/be/vandeas/service/FileService.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 8 additions & 5 deletions src/main/kotlin/be/vandeas/service/impl/FileServiceImpl.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
47 changes: 43 additions & 4 deletions src/test/kotlin/be/vandeas/ApplicationTest.kt
Original file line number Diff line number Diff line change
@@ -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.*
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -88,7 +89,7 @@ class ApplicationTest {
contentType(ContentType.Application.Json)
header("Authorization", token!!)
setBody(
FileCreationOptions(
Base64FileCreationOptions(
path = dirName,
fileName = fileName,
content = testedFile.readBytes().encodeBase64()
Expand Down Expand Up @@ -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()
Expand All @@ -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<ReadFileBytesResult>().content.decodeBase64Bytes().toList())
}
}
}
}

}

0 comments on commit 52443fe

Please sign in to comment.