diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..558d5d4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## 📝 Description + +## 🖼️ Media + +## ✅ Checklist +* [ ] PR has been proofread by the author + +https://cheerz0.atlassian.net/browse/ diff --git a/.github/workflows/pr-title-validation.yml b/.github/workflows/pr-title-validation.yml new file mode 100644 index 0000000..b0f0d61 --- /dev/null +++ b/.github/workflows/pr-title-validation.yml @@ -0,0 +1,20 @@ +name: "Lint PR Title" + +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: pragmatic-tools/pr-title-validator@1.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + pattern: ^(\[(Feat|Fix|Tech|UI)\] ([A-Z\d]+)-\d+ - .+)|(Bump version \d{4}\.\d+\.\d+)|(Release \d{4}\.\d+\.\d+)$ diff --git a/.gitignore b/.gitignore index c426c32..841af2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .gradle build/ !gradle/wrapper/gradle-wrapper.jar +local.properties !**/src/main/**/build/ !**/src/test/**/build/ @@ -33,4 +34,4 @@ out/ /.nb-gradle/ ### VS Code ### -.vscode/ \ No newline at end of file +.vscode/ diff --git a/build.gradle.kts b/build.gradle.kts index 8e3ff26..084af13 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,8 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-server-netty-jvm") implementation("ch.qos.logback:logback-classic:$logback_version") + implementation("com.google.cloud:google-cloud-storage:2.38.0") + testImplementation("io.ktor:ktor-server-tests-jvm") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") } diff --git a/src/main/kotlin/com/cheerz/mediamanager/Application.kt b/src/main/kotlin/com/cheerz/mediamanager/Application.kt index 9c41028..f4ea2e0 100644 --- a/src/main/kotlin/com/cheerz/mediamanager/Application.kt +++ b/src/main/kotlin/com/cheerz/mediamanager/Application.kt @@ -2,14 +2,13 @@ package com.cheerz.mediamanager import com.cheerz.mediamanager.plugins.configureRouting import com.cheerz.mediamanager.plugins.configureSerialization -import io.ktor.server.application.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* +import com.google.cloud.storage.StorageOptions +import io.ktor.server.application.Application +import io.ktor.server.netty.EngineMain -fun main() { - embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module) - .start(wait = true) -} +val storage = StorageOptions.getDefaultInstance().service + +fun main(args: Array): Unit = EngineMain.main(args) fun Application.module() { configureSerialization() diff --git a/src/main/kotlin/com/cheerz/mediamanager/models/CheerzResponse.kt b/src/main/kotlin/com/cheerz/mediamanager/models/CheerzResponse.kt new file mode 100644 index 0000000..c7e49e0 --- /dev/null +++ b/src/main/kotlin/com/cheerz/mediamanager/models/CheerzResponse.kt @@ -0,0 +1,10 @@ +package com.cheerz.mediamanager.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CheerzResponse( + @SerialName("response") val response: Payload, + @SerialName("error_message") val errorMessage: String?, +) diff --git a/src/main/kotlin/com/cheerz/mediamanager/models/MediaItem.kt b/src/main/kotlin/com/cheerz/mediamanager/models/MediaItem.kt index 53c593a..4790e21 100644 --- a/src/main/kotlin/com/cheerz/mediamanager/models/MediaItem.kt +++ b/src/main/kotlin/com/cheerz/mediamanager/models/MediaItem.kt @@ -1,8 +1,15 @@ package com.cheerz.mediamanager.models +import com.google.cloud.storage.Blob +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable data class MediaItem( - val id: String, - val type: MediaType + @SerialName("id") val id: String, + @SerialName("type") val type: MediaType, ) -enum class MediaType { IMAGE, VIDEO } \ No newline at end of file +fun Blob.toMediaItem() = MediaItem(name, MediaType.IMAGE) + +enum class MediaType { IMAGE, VIDEO } diff --git a/src/main/kotlin/com/cheerz/mediamanager/plugins/Routing.kt b/src/main/kotlin/com/cheerz/mediamanager/plugins/Routing.kt index 4cc7b4d..39a342b 100644 --- a/src/main/kotlin/com/cheerz/mediamanager/plugins/Routing.kt +++ b/src/main/kotlin/com/cheerz/mediamanager/plugins/Routing.kt @@ -1,16 +1,12 @@ package com.cheerz.mediamanager.plugins import com.cheerz.mediamanager.routes.* -import io.ktor.server.application.* -import io.ktor.server.routing.* +import io.ktor.server.application.Application +import io.ktor.server.routing.routing fun Application.configureRouting() { routing { homeRoute() - - listMediaRoute() - getMediaRoute() - - testSerializationRoute() + mediaRoutes() } } diff --git a/src/main/kotlin/com/cheerz/mediamanager/plugins/Serialization.kt b/src/main/kotlin/com/cheerz/mediamanager/plugins/Serialization.kt index 467ae94..21ae118 100644 --- a/src/main/kotlin/com/cheerz/mediamanager/plugins/Serialization.kt +++ b/src/main/kotlin/com/cheerz/mediamanager/plugins/Serialization.kt @@ -1,8 +1,9 @@ package com.cheerz.mediamanager.plugins -import io.ktor.serialization.kotlinx.json.* -import io.ktor.server.application.* -import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation fun Application.configureSerialization() { install(ContentNegotiation) { diff --git a/src/main/kotlin/com/cheerz/mediamanager/routes/Experimentations.kt b/src/main/kotlin/com/cheerz/mediamanager/routes/Experimentations.kt deleted file mode 100644 index 76ca9ae..0000000 --- a/src/main/kotlin/com/cheerz/mediamanager/routes/Experimentations.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.cheerz.mediamanager.routes - -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* - - -fun Route.testSerializationRoute() { - get("/json/kotlinx-serialization") { - call.respond(mapOf("hello" to "world")) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/cheerz/mediamanager/routes/Home.kt b/src/main/kotlin/com/cheerz/mediamanager/routes/Home.kt index aa4a2bf..52d3c46 100644 --- a/src/main/kotlin/com/cheerz/mediamanager/routes/Home.kt +++ b/src/main/kotlin/com/cheerz/mediamanager/routes/Home.kt @@ -1,11 +1,12 @@ package com.cheerz.mediamanager.routes -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* +import io.ktor.server.application.call +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.get fun Route.homeRoute() { get("/") { call.respondText("Hello World!") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/cheerz/mediamanager/routes/MediaRoutes.kt b/src/main/kotlin/com/cheerz/mediamanager/routes/MediaRoutes.kt index 5b7c327..bffed8b 100644 --- a/src/main/kotlin/com/cheerz/mediamanager/routes/MediaRoutes.kt +++ b/src/main/kotlin/com/cheerz/mediamanager/routes/MediaRoutes.kt @@ -1,26 +1,104 @@ package com.cheerz.mediamanager.routes -import com.cheerz.mediamanager.storage.mediaStorage -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* - -fun Route.listMediaRoute() { - get("/media") { - if (mediaStorage.isNotEmpty()) { - call.respond(mediaStorage) - } +import com.cheerz.mediamanager.models.CheerzResponse +import com.cheerz.mediamanager.models.MediaItem +import com.cheerz.mediamanager.models.MediaType +import com.cheerz.mediamanager.models.toMediaItem +import com.cheerz.mediamanager.storage +import com.google.cloud.storage.Bucket +import com.google.cloud.storage.Storage +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.PartData +import io.ktor.http.content.forEachPart +import io.ktor.http.content.streamProvider +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.request.receiveMultipart +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytes +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.util.pipeline.PipelineContext + + +fun Route.mediaRoutes() = route("/media") { + listMedias(contentType = "image") + listMedias(contentType = "video") + upload() + download() +} + +private fun Route.listMedias(contentType: String) { + get("/${contentType}s") { + val buckets = getBucket().list(Storage.BlobListOption.currentDirectory()).values + val medias = buckets + .filter { it.contentType?.startsWith(contentType) == true } + .map { it.toMediaItem() } + + call.response.status(HttpStatusCode.OK) + call.respond( + CheerzResponse(medias, null) + ) } } -fun Route.getMediaRoute() { - get("/media/{id?}") { - val id = call.parameters["id"] ?: return@get call.respondText("Bad Request", status = HttpStatusCode.BadRequest) - val media = mediaStorage.find { it.id == id } ?: return@get call.respondText( - "Not Found", - status = HttpStatusCode.NotFound +private fun Route.upload() { + post("/upload") { + val multipart = call.receiveMultipart() + multipart.forEachPart { part -> + when (part) { + is PartData.FileItem -> { + val uuid = java.util.UUID.randomUUID().toString() + val fileBytes = part.streamProvider().readBytes() + + getBucket().create(uuid, fileBytes, part.contentType.toString()) + + val media = MediaItem(uuid, MediaType.IMAGE) + call.response.status(HttpStatusCode.Created) + call.respond( + CheerzResponse(media, null) + ) + } + + else -> { + call.response.status(HttpStatusCode.BadRequest) + call.respond( + CheerzResponse(null, "Bad request") + ) + } + } + part.dispose() + } + + call.response.status(HttpStatusCode.BadRequest) + call.respond( + CheerzResponse(null, "Bad request") ) - call.respond(media) } -} \ No newline at end of file +} + +private fun Route.download() { + get("/download/{id}") { + val id = call.parameters["id"] + + val blob = getBucket().get(id) + + if (blob == null) { + call.response.status(HttpStatusCode.NotFound) + call.respond( + CheerzResponse(null, "Not found") + ) + return@get + } + + call.respondBytes(blob.getContent(), ContentType.Image.JPEG, HttpStatusCode.OK) + } +} + +private fun PipelineContext<*, ApplicationCall>.getBucket(): Bucket { + val bucketName = call.application.environment.config.property("ktor.storage.bucket_name").getString() + return storage.get(bucketName) +} diff --git a/src/main/kotlin/com/cheerz/mediamanager/storage/RamMediaStorage.kt b/src/main/kotlin/com/cheerz/mediamanager/storage/RamMediaStorage.kt deleted file mode 100644 index eb5fb06..0000000 --- a/src/main/kotlin/com/cheerz/mediamanager/storage/RamMediaStorage.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.cheerz.mediamanager.storage - -import com.cheerz.mediamanager.models.MediaItem -import com.cheerz.mediamanager.models.MediaType - -val mediaStorage = listOf( - MediaItem( - "image1", - MediaType.IMAGE, - ), - MediaItem( - "video1", - MediaType.VIDEO, - ) -) \ No newline at end of file diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf new file mode 100644 index 0000000..1766a26 --- /dev/null +++ b/src/main/resources/application.conf @@ -0,0 +1,13 @@ +ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ com.cheerz.mediamanager.ApplicationKt.module ] + } + storage { + bucket_name = "cheerz_medias" + bucket_name = ${?BUCKET_NAME} + } +} diff --git a/src/test/kotlin/com/cheerz/ApplicationTest.kt b/src/test/kotlin/com/cheerz/ApplicationTest.kt index 2349501..67b4f4d 100644 --- a/src/test/kotlin/com/cheerz/ApplicationTest.kt +++ b/src/test/kotlin/com/cheerz/ApplicationTest.kt @@ -1,9 +1,9 @@ package com.cheerz -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.testApplication import kotlin.test.Test import kotlin.test.assertEquals