From e3ea87168769f86dc8d3a9d46d88acb8c47d7d85 Mon Sep 17 00:00:00 2001 From: Maciej Procyk Date: Sat, 16 Mar 2024 20:00:05 +0100 Subject: [PATCH] add redirect type and migrate to content type request --- composeApp/build.gradle.kts | 3 +- .../kotlin/in/procyk/shin/HttpClient.kt | 10 +- .../in/procyk/shin/component/ShinComponent.kt | 41 +++++- .../kotlin/in/procyk/shin/ui/EnumChooser.kt | 62 ++++++++ .../in/procyk/shin/ui/ShortenRequest.kt | 138 +++++++++--------- gradle/libs.versions.toml | 4 +- server/build.gradle.kts | 2 + .../src/main/kotlin/in/procyk/shin/Plugins.kt | 8 + .../src/main/kotlin/in/procyk/shin/Routes.kt | 37 +++-- .../main/kotlin/in/procyk/shin/db/Database.kt | 2 + .../main/kotlin/in/procyk/shin/db/ShortUrl.kt | 28 ++++ .../in/procyk/shin/service/ShortUrlService.kt | 26 +++- shared/build.gradle.kts | 1 + shared/src/commonMain/kotlin/Model.kt | 36 +++++ shared/src/commonMain/kotlin/Option.kt | 2 +- shared/src/commonMain/kotlin/Resources.kt | 11 -- 16 files changed, 297 insertions(+), 114 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/in/procyk/shin/ui/EnumChooser.kt create mode 100644 shared/src/commonMain/kotlin/Model.kt delete mode 100644 shared/src/commonMain/kotlin/Resources.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5f12491..0301cf5 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -72,7 +72,8 @@ kotlin { implementation(projects.shared) implementation(libs.ktor.client.core) - implementation(libs.ktor.client.resources) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.cbor) implementation(libs.procyk.compose.qrcode) implementation(libs.procyk.compose.calendar) diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/HttpClient.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/HttpClient.kt index 530f35a..a1cdc05 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/HttpClient.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/HttpClient.kt @@ -1,12 +1,18 @@ package `in`.procyk.shin +import ShinCbor import io.ktor.client.* import io.ktor.client.plugins.* -import io.ktor.client.plugins.resources.* +import io.ktor.client.plugins.contentnegotiation.* import io.ktor.http.* +import io.ktor.serialization.kotlinx.cbor.* +import kotlinx.serialization.ExperimentalSerializationApi +@OptIn(ExperimentalSerializationApi::class) internal fun createHttpClient(): HttpClient = HttpClient { - install(Resources) + install(ContentNegotiation) { + cbor(ShinCbor) + } defaultRequest { host = ComposeAppConfig.CLIENT_HOST url { diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/component/ShinComponent.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/component/ShinComponent.kt index 60babf4..e4c3e2d 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/component/ShinComponent.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/component/ShinComponent.kt @@ -3,14 +3,16 @@ package `in`.procyk.shin.component import Option import Option.None import Option.Some +import RedirectType +import SHORTEN_PATH +import ShinCbor import Shorten -import ShortenExpiring import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import `in`.procyk.shin.createHttpClient import `in`.procyk.shin.model.ShortenedProtocol import io.ktor.client.* -import io.ktor.client.plugins.resources.* +import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.coroutines.flow.MutableStateFlow @@ -18,8 +20,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.* import kotlinx.datetime.Clock.System.now +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToByteArray import toLocalDate -import toNullable interface ShinComponent : Component { @@ -30,6 +33,10 @@ interface ShinComponent : Component { val expirationDateVisible: Value + val redirectType: Value + + val redirectTypeVisible: Value + val url: Value val shortenedUrl: Value> @@ -42,6 +49,10 @@ interface ShinComponent : Component { fun onExpirationDateVisibleChange(visible: Boolean) + fun onRedirectTypeChange(redirectType: RedirectType) + + fun onRedirectTypeVisibleChange(visible: Boolean) + fun onUrlChange(url: String) fun onProtocolChange(protocol: ShortenedProtocol) @@ -67,6 +78,12 @@ class ShinComponentImpl( private val _expirationDateVisible = MutableStateFlow(false) override val expirationDateVisible: Value = _expirationDateVisible.asValue() + private val _redirectType = MutableStateFlow(RedirectType.Default) + override val redirectType: Value = _redirectType.asValue() + + private val _redirectTypeVisible = MutableStateFlow(false) + override val redirectTypeVisible: Value = _redirectTypeVisible.asValue() + private val _url = MutableStateFlow("") override val url: Value = _url.asValue() @@ -99,6 +116,14 @@ class ShinComponentImpl( _expirationDateVisible.update { visible } } + override fun onRedirectTypeChange(redirectType: RedirectType) { + _redirectType.update { redirectType } + } + + override fun onRedirectTypeVisibleChange(visible: Boolean) { + _redirectTypeVisible.update { visible } + } + override fun onUrlChange(url: String) { val (updatedUrl, updatedProtocol) = ShortenedProtocol.simplifyInputUrl(url) updatedProtocol?.let { protocol -> _protocol.update { protocol } } @@ -120,6 +145,7 @@ class ShinComponentImpl( url = _url.value, shortenedProtocol = _protocol.value, expirationDate = _expirationDate.value.takeIfExtraElementsVisibleAnd(expirationDateVisible), + redirectType = _redirectType.value.takeIfExtraElementsVisibleAnd(redirectTypeVisible), onResponse = { response -> val some = Some(response) _shortenedUrl.update { some } @@ -133,18 +159,21 @@ class ShinComponentImpl( takeIf { _extraElementsVisible.value && value.value } } +@OptIn(ExperimentalSerializationApi::class) private suspend fun HttpClient.requestShortenedUrl( url: String, shortenedProtocol: ShortenedProtocol, expirationDate: LocalDate?, + redirectType: RedirectType?, onResponse: (String) -> Unit, onError: (String) -> Unit, ) { try { val expirationAt = expirationDate?.plus(1, DateTimeUnit.DAY)?.atStartOfDayIn(TimeZone.currentSystemDefault()) - val response = when (expirationAt) { - null -> post<_>(Shorten(shortenedProtocol.buildUrl(url))) - else -> post<_>(ShortenExpiring(shortenedProtocol.buildUrl(url), expirationAt)) + val shorten = Shorten(shortenedProtocol.buildUrl(url), expirationAt, redirectType) + val response = post(SHORTEN_PATH) { + contentType(ContentType.Application.Cbor) + setBody(ShinCbor.encodeToByteArray(shorten)) } response .takeIf { it.status == HttpStatusCode.OK } diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/EnumChooser.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/EnumChooser.kt new file mode 100644 index 0000000..61e3047 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/EnumChooser.kt @@ -0,0 +1,62 @@ +package `in`.procyk.shin.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import applyIf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EnumChooser( + label: String, + entries: Iterable, + value: T, + onValueChange: (T) -> Unit, + presentableName: T.() -> String, + fillMaxWidth: Boolean = true, + defaultWidth: Dp = 128.dp, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + readOnly = true, + value = value.presentableName(), + onValueChange = {}, + label = { Text(label) }, + modifier = Modifier + .menuAnchor() + .height(64.dp) + .applyIf(fillMaxWidth) { fillMaxWidth() } + .width(defaultWidth), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + entries.forEach { protocol -> + DropdownMenuItem( + text = { + Text(protocol.presentableName()) + }, + onClick = { + onValueChange(protocol) + expanded = false + }) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenRequest.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenRequest.kt index 7a5f1ab..3886e53 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenRequest.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenRequest.kt @@ -1,5 +1,6 @@ package `in`.procyk.shin.ui +import RedirectType import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.* @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import applyIf import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.value.Value import `in`.procyk.compose.calendar.SelectableCalendar import `in`.procyk.compose.calendar.rememberSelectableCalendarState import `in`.procyk.compose.calendar.year.YearMonth @@ -91,26 +93,17 @@ private fun ShortenRequestExtraElements( ) } VerticalAnimatedVisibility(extraElementsVisible) { - Column( - modifier = Modifier - .padding(top = 12.dp) - .sizeIn(maxWidth = 280.dp), - verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), ) { - val expirationDate by component.expirationDate.subscribeAsState() - val expirationDateVisible by component.expirationDateVisible.subscribeAsState() - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically + HideableSettingsColumn( + name = "Expiration Date", + visible = component.expirationDateVisible, + modifier = Modifier.sizeIn(maxWidth = 250.dp), + onVisibleChange = component::onExpirationDateVisibleChange, ) { - Text("Expiration Date", fontSize = 16.sp) - Switch( - checked = expirationDateVisible, - onCheckedChange = component::onExpirationDateVisibleChange - ) - } - VerticalAnimatedVisibility(expirationDateVisible) { + val expirationDate by component.expirationDate.subscribeAsState() val calendarState = rememberSelectableCalendarState( initialMonth = YearMonth.now(), minMonth = YearMonth.now(), @@ -119,6 +112,58 @@ private fun ShortenRequestExtraElements( ) SelectableCalendar(calendarState = calendarState) } + HideableSettingsColumn( + name = "Redirect Type", + visible = component.redirectTypeVisible, + modifier = Modifier.width(width = 250.dp), + onVisibleChange = component::onRedirectTypeVisibleChange, + ) { + val redirectType by component.redirectType.subscribeAsState() + EnumChooser( + label = "Redirect Type", + entries = RedirectType.entries, + value = redirectType, + onValueChange = component::onRedirectTypeChange, + presentableName = RedirectType::presentableName, + ) + } + } + } +} + +private inline val RedirectType.presentableName: String + get() = when (this) { + RedirectType.MovedPermanently -> "301 (Moved Permanently)" + RedirectType.Found -> "302 (Moved Temporarily)" + } + +@Composable +private inline fun HideableSettingsColumn( + name: String, + visible: Value, + noinline onVisibleChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + crossinline content: @Composable() AnimatedVisibilityScope.() -> Unit, +) { + Column( + modifier = modifier + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val contentVisible by visible.subscribeAsState() + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + Text(name, fontSize = 16.sp) + Switch( + checked = contentVisible, + onCheckedChange = onVisibleChange, + ) + } + VerticalAnimatedVisibility(contentVisible) { + content() } } } @@ -132,10 +177,13 @@ private fun ShortenRequestElements( val url by component.url.subscribeAsState() val protocol by component.protocol.subscribeAsState() - ProtocolChooser( - protocol = protocol, - onChange = component::onProtocolChange, - fillMaxWidth = fillMaxWidth + EnumChooser( + label = "Protocol", + entries = ShortenedProtocol.entries, + value = protocol, + onValueChange = component::onProtocolChange, + presentableName = ShortenedProtocol::presentableName, + fillMaxWidth = fillMaxWidth, ) val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -175,52 +223,6 @@ private fun ShortenRequestElements( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProtocolChooser( - protocol: ShortenedProtocol, - onChange: (ShortenedProtocol) -> Unit, - fillMaxWidth: Boolean, -) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - OutlinedTextField( - readOnly = true, - value = protocol.presentableName, - onValueChange = {}, - label = { Text("Protocol") }, - modifier = Modifier - .menuAnchor() - .height(64.dp) - .applyIf(fillMaxWidth) { fillMaxWidth() } - .width(128.dp), - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - ShortenedProtocol.entries.forEach { protocol -> - DropdownMenuItem( - text = { - Text(protocol.presentableName) - }, - onClick = { - onChange(protocol) - expanded = false - }) - } - } - } -} - @Composable private inline fun VerticalAnimatedVisibility( visible: Boolean, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef40ff3..7a2390c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,17 +31,19 @@ exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "e exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } ktor-shared-resources = { module = "io.ktor:ktor-resources", version.ref = "ktor-client" } +ktor-serialization-kotlinx-cbor = { module = "io.ktor:ktor-serialization-kotlinx-cbor", version.ref = "ktor-client" } ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor-server" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor-server" } ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor-server" } ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor-server" } ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref = "ktor-server" } +ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor-server" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor-client" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor-client" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor-client" } ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-client" } -ktor-client-resources = { module = "io.ktor:ktor-client-resources", version.ref = "ktor-client" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-client" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index a4c390c..5fdb94e 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -20,6 +20,8 @@ dependencies { implementation(libs.ktor.server.cors) implementation(libs.ktor.server.netty) implementation(libs.ktor.server.resources) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.cbor) implementation(libs.koin.ktor) implementation(libs.exposed.core) diff --git a/server/src/main/kotlin/in/procyk/shin/Plugins.kt b/server/src/main/kotlin/in/procyk/shin/Plugins.kt index c60023f..e416932 100644 --- a/server/src/main/kotlin/in/procyk/shin/Plugins.kt +++ b/server/src/main/kotlin/in/procyk/shin/Plugins.kt @@ -1,19 +1,27 @@ package `in`.procyk.shin +import ShinCbor import `in`.procyk.shin.util.env import io.github.cdimascio.dotenv.Dotenv import io.ktor.http.* +import io.ktor.serialization.kotlinx.cbor.* import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.resources.* +import kotlinx.serialization.ExperimentalSerializationApi import org.koin.core.module.Module import org.koin.ktor.plugin.Koin +@OptIn(ExperimentalSerializationApi::class) internal fun Application.installPlugins( dotenv: Dotenv, appModule: Module, ) { install(Resources) + install(ContentNegotiation) { + cbor(ShinCbor) + } installCors(dotenv) install(Koin) { modules(appModule) diff --git a/server/src/main/kotlin/in/procyk/shin/Routes.kt b/server/src/main/kotlin/in/procyk/shin/Routes.kt index 441f0ab..fe22eb1 100644 --- a/server/src/main/kotlin/in/procyk/shin/Routes.kt +++ b/server/src/main/kotlin/in/procyk/shin/Routes.kt @@ -1,20 +1,22 @@ package `in`.procyk.shin import Decode +import RedirectType +import SHORTEN_PATH import Shorten -import ShortenExpiring import `in`.procyk.shin.service.ShortUrlService import `in`.procyk.shin.util.env import io.github.cdimascio.dotenv.Dotenv import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.request.* import io.ktor.server.resources.* -import io.ktor.server.resources.post +import io.ktor.server.resources.get as getResource import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.util.pipeline.* import kotlinx.coroutines.* -import kotlinx.datetime.Instant +import io.ktor.server.routing.post as postBody import org.koin.ktor.ext.inject import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -27,14 +29,12 @@ internal fun Application.installRoutes(): Routing = routing { GlobalScope.launch { deleteExpiredUrlsEvery(1.hours, service) } - post { - handleShorten(service, redirectBaseUrl, it.url) + postBody(SHORTEN_PATH) { + val shorten = call.receive() + handleShorten(service, redirectBaseUrl, shorten) } - post { - handleShorten(service, redirectBaseUrl, it.url, it.expirationAt) - } - get { - handleDecode(service, it.shortenedId) + getResource { + handleDecode(service, it) } } @@ -53,19 +53,24 @@ private suspend inline fun deleteExpiredUrlsEvery( private suspend inline fun PipelineContext.handleShorten( service: ShortUrlService, redirectBaseUrl: String, - url: String, - expirationAt: Instant? = null, + shorten: Shorten, ) { - val shortId = service.findOrCreateShortenedId(url, expirationAt) + val shortId = service.findOrCreateShortenedId(shorten) if (shortId != null) call.respond(HttpStatusCode.OK, "$redirectBaseUrl$shortId") else call.respond(HttpStatusCode.BadRequest) } private suspend inline fun PipelineContext.handleDecode( service: ShortUrlService, - shortenedId: String, + decode: Decode, ) { - val url = service.findShortenedUrl(shortenedId) - if (url != null) call.respondRedirect(url, permanent = true) + val shortened = service.findShortenedUrl(decode.shortenedId) + if (shortened != null) call.respondRedirect(shortened.url, permanent = shortened.redirectType.isPermanent) else call.respond(HttpStatusCode.NotFound) } + +private inline val RedirectType.isPermanent: Boolean + get() = when (this) { + RedirectType.MovedPermanently -> true + RedirectType.Found -> false + } diff --git a/server/src/main/kotlin/in/procyk/shin/db/Database.kt b/server/src/main/kotlin/in/procyk/shin/db/Database.kt index 63a6d71..98d089e 100644 --- a/server/src/main/kotlin/in/procyk/shin/db/Database.kt +++ b/server/src/main/kotlin/in/procyk/shin/db/Database.kt @@ -1,5 +1,6 @@ package `in`.procyk.shin.db +import RedirectType import `in`.procyk.shin.util.env import io.github.cdimascio.dotenv.Dotenv import org.jetbrains.exposed.sql.Database @@ -14,6 +15,7 @@ object Database { password = dotenv.env("POSTGRES_PASSWORD"), ) transaction { + exec(CREATE_REDIRECT_TYPE) SchemaUtils.createMissingTablesAndColumns(ShortUrls) } } diff --git a/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt b/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt index e7ca87d..1d0de2c 100644 --- a/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt +++ b/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt @@ -1,10 +1,12 @@ package `in`.procyk.shin.db +import RedirectType import org.jetbrains.exposed.dao.Entity import org.jetbrains.exposed.dao.EntityClass import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.sql.kotlin.datetime.timestamp +import org.postgresql.util.PGobject internal object ShortUrls : IdTable() { override val id = varchar("id", 44).entityId() @@ -12,6 +14,12 @@ internal object ShortUrls : IdTable() { val url = text("url") val expirationAt = timestamp("expiration_at").nullable() + val redirectType = customEnumeration( + name = "redirect_type", + sql = "redirect_type", + fromDb = { value -> RedirectType.valueOf(value as String) }, + toDb = { PGEnum("redirect_type", it) }, + ) } internal class ShortUrl(id: EntityID) : Entity(id) { @@ -19,4 +27,24 @@ internal class ShortUrl(id: EntityID) : Entity(id) { var url by ShortUrls.url var expirationAt by ShortUrls.expirationAt + var redirectType by ShortUrls.redirectType +} + +val CREATE_REDIRECT_TYPE = """ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'redirect_type') THEN + CREATE TYPE redirect_type AS ENUM + ( + ${RedirectType.entries.joinToString { "'$it'" }} + ); + END IF; +END$$; +""" + +private class PGEnum>(enumTypeName: String, enumValue: T?) : PGobject() { + init { + value = enumValue?.name + type = enumTypeName + } } \ No newline at end of file diff --git a/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt b/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt index 73eb0ad..4736b0b 100644 --- a/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt +++ b/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt @@ -1,9 +1,10 @@ package `in`.procyk.shin.service +import RedirectType +import Shorten import `in`.procyk.shin.db.ShortUrl import `in`.procyk.shin.db.ShortUrls import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import org.jetbrains.exposed.sql.SqlExpressionBuilder.isNotNull import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq import org.jetbrains.exposed.sql.and @@ -16,9 +17,9 @@ import java.security.MessageDigest import java.util.* internal interface ShortUrlService { - suspend fun findOrCreateShortenedId(rawUrl: String, expirationAt: Instant?): String? + suspend fun findOrCreateShortenedId(shorten: Shorten): String? - suspend fun findShortenedUrl(shortenedId: String): String? + suspend fun findShortenedUrl(shortenedId: String): ShortenedUrl? suspend fun deleteExpiredUrls() } @@ -28,10 +29,11 @@ internal fun Module.singleShortUrlService() { } private class ShortUrlServiceImpl : ShortUrlService { - override suspend fun findOrCreateShortenedId(rawUrl: String, expirationAt: Instant?): String? { - val url = rawUrl.normalizeAsUrl() ?: return null + override suspend fun findOrCreateShortenedId(shorten: Shorten): String? { + val url = shorten.url.normalizeUrlCase() ?: return null val id = url.sha256() + val expirationAt = shorten.expirationAt return newSuspendedTransaction txn@{ for (n in 1..id.length) { val shortId = id.take(n) @@ -40,6 +42,7 @@ private class ShortUrlServiceImpl : ShortUrlService { null -> ShortUrl.new(shortId) { this.url = url this.expirationAt = expirationAt + this.redirectType = RedirectType.from(shorten.redirectType) }.let { return@txn shortId } url -> { @@ -63,8 +66,10 @@ private class ShortUrlServiceImpl : ShortUrlService { } } - override suspend fun findShortenedUrl(shortenedId: String): String? = newSuspendedTransaction { - ShortUrl.findById(shortenedId)?.url + override suspend fun findShortenedUrl(shortenedId: String): ShortenedUrl? = newSuspendedTransaction { + ShortUrl.findById(shortenedId) + }?.let { + ShortenedUrl(it.url, it.redirectType) } override suspend fun deleteExpiredUrls() { @@ -75,7 +80,12 @@ private class ShortUrlServiceImpl : ShortUrlService { } } -fun String.normalizeAsUrl(): String? = try { +data class ShortenedUrl( + val url: String, + val redirectType: RedirectType, +) + +private fun String.normalizeUrlCase(): String? = try { val uri = URI(this) val normalizedUri = URI( uri.scheme?.lowercase(), diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 323f307..5be9fb0 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { sourceSets { commonMain.dependencies { + implementation(libs.ktor.serialization.kotlinx.cbor) implementation(libs.ktor.shared.resources) implementation(libs.kotlinx.datetime) } diff --git a/shared/src/commonMain/kotlin/Model.kt b/shared/src/commonMain/kotlin/Model.kt new file mode 100644 index 0000000..053eb7e --- /dev/null +++ b/shared/src/commonMain/kotlin/Model.kt @@ -0,0 +1,36 @@ +import io.ktor.resources.* +import kotlinx.datetime.Instant +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.Cbor + +const val SHORTEN_PATH: String = "/shorten" + +@Serializable +enum class RedirectType { + MovedPermanently, + Found, + ; + + companion object { + val Default: RedirectType = MovedPermanently + + fun from(redirectType: RedirectType?): RedirectType = redirectType ?: Default + } +} + +@Serializable +class Shorten( + val url: String, + val expirationAt: Instant?, + val redirectType: RedirectType?, +) + +@Resource("/{shortenedId}") +class Decode(val shortenedId: String) + +@OptIn(ExperimentalSerializationApi::class) +val ShinCbor = Cbor { + encodeDefaults = true + ignoreUnknownKeys = true +} diff --git a/shared/src/commonMain/kotlin/Option.kt b/shared/src/commonMain/kotlin/Option.kt index ffcfd7e..988c8f2 100644 --- a/shared/src/commonMain/kotlin/Option.kt +++ b/shared/src/commonMain/kotlin/Option.kt @@ -11,7 +11,7 @@ sealed interface Option { } } -inline fun Option.toNullable(): A? = when (this) { +fun Option.toNullable(): A? = when (this) { is Option.Some -> value else -> null } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/Resources.kt b/shared/src/commonMain/kotlin/Resources.kt deleted file mode 100644 index e8543c6..0000000 --- a/shared/src/commonMain/kotlin/Resources.kt +++ /dev/null @@ -1,11 +0,0 @@ -import io.ktor.resources.* -import kotlinx.datetime.Instant - -@Resource("/shorten") -class Shorten(val url: String) - -@Resource("/shorten") -class ShortenExpiring(val url: String, val expirationAt: Instant) - -@Resource("/{shortenedId}") -class Decode(val shortenedId: String)