From 2a8fd5d2973931b9cc0a1ccc89c95f0167667aeb Mon Sep 17 00:00:00 2001 From: Maciej Procyk Date: Tue, 12 Mar 2024 23:23:07 +0100 Subject: [PATCH] add expiration date --- composeApp/build.gradle.kts | 1 + .../commonMain/kotlin/in/procyk/shin/App.kt | 9 +- .../in/procyk/shin/component/ShinComponent.kt | 62 +++++++++-- .../in/procyk/shin/ui/ShortenRequest.kt | 102 +++++++++++++----- .../in/procyk/shin/ui/ShortenResponse.kt | 2 + .../in/procyk/shin/ui/util/DateTimeUtil.kt | 4 + gradle/libs.versions.toml | 6 +- server/build.gradle.kts | 2 + .../src/main/kotlin/in/procyk/shin/Routes.kt | 56 ++++++++-- .../main/kotlin/in/procyk/shin/db/ShortUrl.kt | 3 + .../in/procyk/shin/service/ShortUrlService.kt | 45 ++++++-- settings.gradle.kts | 1 + shared/build.gradle.kts | 1 + shared/src/commonMain/kotlin/Resources.kt | 4 + 14 files changed, 241 insertions(+), 57 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/in/procyk/shin/ui/util/DateTimeUtil.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8824b25..5f12491 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -75,6 +75,7 @@ kotlin { implementation(libs.ktor.client.resources) implementation(libs.procyk.compose.qrcode) + implementation(libs.procyk.compose.calendar) implementation(libs.decompose) implementation(libs.decompose.extensionsComposeJetbrains) diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt index 741a5ee..97d920f 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt @@ -55,14 +55,17 @@ fun ShinApp(component: ShinComponent) { textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - Spacer(Modifier.size(32.dp)) + Spacer(Modifier.size(16.dp)) + + val shortenedUrl by component.shortenedUrl.subscribeAsState() + ShortenResponse(shortenedUrl.toNullable()) + + Spacer(Modifier.size(16.dp)) ShortenRequest( component = component, maxWidth = maxWidth, isVertical = isVertical, ) - val shortenedUrl by component.shortenedUrl.subscribeAsState() - ShortenResponse(shortenedUrl.toNullable()) } } } 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 08dd6c1..45a10f8 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/component/ShinComponent.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/component/ShinComponent.kt @@ -1,8 +1,10 @@ package `in`.procyk.shin.component import Option +import Option.None import Option.Some import Shorten +import ShortenExpiring import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import `in`.procyk.shin.createHttpClient @@ -14,11 +16,16 @@ import io.ktor.http.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.datetime.Instant +import kotlinx.datetime.* +import kotlinx.datetime.Clock.System.now +import toLocalDate + interface ShinComponent : Component { - val expirationDateTime: Value> + val extraElementsVisible: Value + + val expirationDate: Value val url: Value @@ -26,7 +33,9 @@ interface ShinComponent : Component { val protocol: Value - fun onExpirationDateTimeChange(expirationDateTime: Instant?) + fun onExtraElementsVisibleChange() + + fun onExpirationDateTimeChange(expirationDate: LocalDate?): Boolean fun onUrlChange(url: String) @@ -44,21 +53,38 @@ class ShinComponentImpl( private val httpClient: HttpClient = createHttpClient() - private val _expirationDateTime = MutableStateFlow>(Option.None) - override val expirationDateTime: Value> = _expirationDateTime.asValue() + private val _extraElementsVisible = MutableStateFlow(false) + override val extraElementsVisible: Value = _extraElementsVisible.asValue() + + private val _expirationDate = MutableStateFlow(tomorrow) + override val expirationDate: Value = _expirationDate.asValue() private val _url = MutableStateFlow("") override val url: Value = _url.asValue() - private val _shortenedUrl = MutableStateFlow>(Option.None) + private val _shortenedUrl = MutableStateFlow>(None) override val shortenedUrl: Value> = _shortenedUrl.asValue() private val _protocol = MutableStateFlow(ShortenedProtocol.HTTPS) override val protocol: Value = _protocol.asValue() - override fun onExpirationDateTimeChange(expirationDateTime: Instant?) { - val updatedValue = Option.fromNullable(expirationDateTime) - _expirationDateTime.update { updatedValue } + override fun onExtraElementsVisibleChange() { + _extraElementsVisible.update { !it } + } + + override fun onExpirationDateTimeChange(expirationDate: LocalDate?): Boolean = when { + expirationDate == null -> { + val updatedDate = tomorrow + _expirationDate.update { updatedDate } + true + } + + expirationDate < now().toLocalDate() -> false + + else -> { + _expirationDate.update { expirationDate } + true + } } override fun onUrlChange(url: String) { @@ -72,7 +98,8 @@ class ShinComponentImpl( } override fun onShortenedUrlReset() { - _shortenedUrl.update { Option.None } + _shortenedUrl.update { None } + _extraElementsVisible.update { false } } override fun onShorten() { @@ -80,6 +107,7 @@ class ShinComponentImpl( httpClient.requestShortenedUrl( url = _url.value, shortenedProtocol = _protocol.value, + expirationDate = _expirationDate.value.takeIfVisible(), onResponse = { response -> val some = Some(response) _shortenedUrl.update { some } @@ -88,16 +116,25 @@ class ShinComponentImpl( ) } } + + private inline fun T.takeIfVisible(): T? = + takeIf { _extraElementsVisible.value } } private suspend fun HttpClient.requestShortenedUrl( url: String, shortenedProtocol: ShortenedProtocol, + expirationDate: LocalDate?, onResponse: (String) -> Unit, onError: (String) -> Unit, ) { try { - post(Shorten(shortenedProtocol.buildUrl(url))) + 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)) + } + response .takeIf { it.status == HttpStatusCode.OK } ?.bodyAsText() ?.let(onResponse) @@ -105,3 +142,6 @@ private suspend fun HttpClient.requestShortenedUrl( onError("Cannot connect to Shin. Try again later…") } } + +private inline val tomorrow: LocalDate + get() = now().toLocalDate().plus(1, DateTimeUnit.DAY) 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 7805110..9bc1c65 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenRequest.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenRequest.kt @@ -1,13 +1,18 @@ package `in`.procyk.shin.ui +import androidx.compose.animation.* +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -17,6 +22,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import applyIf import com.arkivanov.decompose.extensions.compose.subscribeAsState +import `in`.procyk.compose.calendar.SelectableCalendar +import `in`.procyk.compose.calendar.rememberSelectableCalendarState +import `in`.procyk.compose.calendar.year.YearMonth import `in`.procyk.shin.component.ShinComponent import `in`.procyk.shin.model.ShortenedProtocol @@ -27,38 +35,78 @@ internal fun ShortenRequest( isVertical: Boolean, space: Dp = 8.dp, ) { - if (isVertical) { - Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = Arrangement.spacedBy( - space = space, - alignment = Alignment.CenterVertically, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ShortenRequestElements( - component = component, - fillMaxWidth = true, - maxTextFieldWidth = maxWidth / 2 - ) + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when { + isVertical -> Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + space = space, + alignment = Alignment.CenterVertically, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShortenRequestElements( + component = component, + fillMaxWidth = true, + maxTextFieldWidth = maxWidth / 2 + ) + } + + else -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = space, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.Bottom, + ) { + ShortenRequestElements( + component = component, + fillMaxWidth = false, + maxTextFieldWidth = maxWidth / 2 + ) + } } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - space = space, - alignment = Alignment.CenterHorizontally, - ), - verticalAlignment = Alignment.Bottom, + Spacer(Modifier.height(12.dp)) + ShortenRequestExtraElements(component) + } +} + +@Composable +private fun ShortenRequestExtraElements( + component: ShinComponent, +) { + val extraElementsVisible by component.extraElementsVisible.subscribeAsState() + val expirationDate by component.expirationDate.subscribeAsState() + val rotation by animateFloatAsState(if (extraElementsVisible) 180f else 0f) + OutlinedButton(onClick = component::onExtraElementsVisibleChange) { + Text("Extra Options") + Icon( + imageVector = Icons.Filled.ArrowDropDown, + modifier = Modifier.rotate(rotation), + contentDescription = "Extra Options" + ) + } + AnimatedVisibility( + visible = extraElementsVisible, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() + ) { + Box( + modifier = Modifier.sizeIn(maxWidth = 280.dp), ) { - ShortenRequestElements( - component = component, - fillMaxWidth = false, - maxTextFieldWidth = maxWidth / 2 + val calendarState = rememberSelectableCalendarState( + initialMonth = YearMonth.now(), + minMonth = YearMonth.now(), + initialSelection = listOf(expirationDate), + confirmSelectionChange = { component.onExpirationDateTimeChange(it.singleOrNull()) }, ) + SelectableCalendar(calendarState = calendarState) } } - } @Composable diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenResponse.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenResponse.kt index 9434b98..b6f9034 100644 --- a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenResponse.kt +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/ShortenResponse.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import applyIf @Composable internal fun ShortenResponse( diff --git a/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/util/DateTimeUtil.kt b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/util/DateTimeUtil.kt new file mode 100644 index 0000000..3856655 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/in/procyk/shin/ui/util/DateTimeUtil.kt @@ -0,0 +1,4 @@ +import kotlinx.datetime.* + +fun Instant.toLocalDate(): LocalDate = + toLocalDateTime(TimeZone.currentSystemDefault()).date diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f900e2c..33b7d94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,13 +7,13 @@ androidx-activityCompose = "1.8.2" jetbrains-compose = "1.6.0-rc02" exposed = "0.47.0" kotlin = "1.9.22" -ktor-server = "2.3.8" +ktor-server = "2.3.9" ktor-client = "3.0.0-wasm2" koin = "3.6.0-wasm-alpha2" kotlinxDatetime = "0.6.0-RC.2" kotlinxCoroutines = "1.8.0" kotlinxSerialization = "1.6.3" -procyk-compose = "1.6.0-rc02.0" +procyk-compose = "1.6.0-rc02.2" logback = "1.4.14" postgres = "42.7.1" dotenv = "6.4.1" @@ -28,6 +28,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } +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-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor-server" } @@ -48,6 +49,7 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } procyk-compose-qrcode = { module = "in.procyk.compose:qr-code", version.ref = "procyk-compose" } +procyk-compose-calendar = { module = "in.procyk.compose:calendar", version.ref = "procyk-compose" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } dotenv = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" } decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index b602b6c..a4c390c 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -25,8 +25,10 @@ dependencies { implementation(libs.exposed.core) implementation(libs.exposed.dao) implementation(libs.exposed.jdbc) + implementation(libs.exposed.kotlin.datetime) implementation(libs.postgres) implementation(libs.dotenv) + implementation(libs.kotlinx.datetime) testImplementation(libs.ktor.server.tests) testImplementation(libs.kotlin.test.junit) diff --git a/server/src/main/kotlin/in/procyk/shin/Routes.kt b/server/src/main/kotlin/in/procyk/shin/Routes.kt index cf12ff8..441f0ab 100644 --- a/server/src/main/kotlin/in/procyk/shin/Routes.kt +++ b/server/src/main/kotlin/in/procyk/shin/Routes.kt @@ -2,6 +2,7 @@ package `in`.procyk.shin import Decode import Shorten +import ShortenExpiring import `in`.procyk.shin.service.ShortUrlService import `in`.procyk.shin.util.env import io.github.cdimascio.dotenv.Dotenv @@ -11,21 +12,60 @@ import io.ktor.server.resources.* import io.ktor.server.resources.post import io.ktor.server.response.* import io.ktor.server.routing.* +import io.ktor.util.pipeline.* +import kotlinx.coroutines.* +import kotlinx.datetime.Instant import org.koin.ktor.ext.inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +@OptIn(DelicateCoroutinesApi::class) internal fun Application.installRoutes(): Routing = routing { val service by inject() val dotenv by inject() val redirectBaseUrl = dotenv.env("REDIRECT_BASE_URL") + GlobalScope.launch { + deleteExpiredUrlsEvery(1.hours, service) + } post { - val shortId = service.findOrCreateShortenedId(it.url) - if (shortId != null) call.respond(HttpStatusCode.OK, "$redirectBaseUrl$shortId") - else call.respond(HttpStatusCode.BadRequest) + handleShorten(service, redirectBaseUrl, it.url) + } + post { + handleShorten(service, redirectBaseUrl, it.url, it.expirationAt) } get { - val shortenedId = it.shortenedId - val url = service.findShortenedUrl(shortenedId) - if (url != null) call.respondRedirect(url, permanent = true) - else call.respond(HttpStatusCode.NotFound) + handleDecode(service, it.shortenedId) + } +} + +private suspend inline fun deleteExpiredUrlsEvery( + duration: Duration, + service: ShortUrlService, +) { + coroutineScope { + while (isActive) { + service.deleteExpiredUrls() + delay(duration) + } } -} \ No newline at end of file +} + +private suspend inline fun PipelineContext.handleShorten( + service: ShortUrlService, + redirectBaseUrl: String, + url: String, + expirationAt: Instant? = null, +) { + val shortId = service.findOrCreateShortenedId(url, expirationAt) + if (shortId != null) call.respond(HttpStatusCode.OK, "$redirectBaseUrl$shortId") + else call.respond(HttpStatusCode.BadRequest) +} + +private suspend inline fun PipelineContext.handleDecode( + service: ShortUrlService, + shortenedId: String, +) { + val url = service.findShortenedUrl(shortenedId) + if (url != null) call.respondRedirect(url, permanent = true) + else call.respond(HttpStatusCode.NotFound) +} 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 37ba0a6..e7ca87d 100644 --- a/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt +++ b/server/src/main/kotlin/in/procyk/shin/db/ShortUrl.kt @@ -4,16 +4,19 @@ 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 internal object ShortUrls : IdTable() { override val id = varchar("id", 44).entityId() override val primaryKey = PrimaryKey(id) val url = text("url") + val expirationAt = timestamp("expiration_at").nullable() } internal class ShortUrl(id: EntityID) : Entity(id) { companion object : EntityClass(ShortUrls) var url by ShortUrls.url + var expirationAt by ShortUrls.expirationAt } \ 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 90a61df..73eb0ad 100644 --- a/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt +++ b/server/src/main/kotlin/in/procyk/shin/service/ShortUrlService.kt @@ -1,6 +1,13 @@ package `in`.procyk.shin.service 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 +import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.koin.core.module.Module import java.net.URI @@ -9,17 +16,19 @@ import java.security.MessageDigest import java.util.* internal interface ShortUrlService { - suspend fun findOrCreateShortenedId(rawUrl: String): String? + suspend fun findOrCreateShortenedId(rawUrl: String, expirationAt: Instant?): String? suspend fun findShortenedUrl(shortenedId: String): String? + + suspend fun deleteExpiredUrls() } internal fun Module.singleShortUrlService() { - single { ShortUrlServiceImpl } + single { ShortUrlServiceImpl() } } -private object ShortUrlServiceImpl : ShortUrlService { - override suspend fun findOrCreateShortenedId(rawUrl: String): String? { +private class ShortUrlServiceImpl : ShortUrlService { + override suspend fun findOrCreateShortenedId(rawUrl: String, expirationAt: Instant?): String? { val url = rawUrl.normalizeAsUrl() ?: return null val id = url.sha256() @@ -28,8 +37,25 @@ private object ShortUrlServiceImpl : ShortUrlService { val shortId = id.take(n) val existing = ShortUrl.findById(shortId) when (existing?.url) { - null -> ShortUrl.new(shortId) { this.url = url }.let { return@txn shortId } - url -> return@txn shortId + null -> ShortUrl.new(shortId) { + this.url = url + this.expirationAt = expirationAt + }.let { return@txn shortId } + + url -> { + val prevExpirationAt = existing.expirationAt + when { + prevExpirationAt == null -> return@txn shortId + + expirationAt == null || expirationAt > prevExpirationAt -> { + existing.expirationAt = expirationAt + return@txn shortId + } + + else -> return@txn shortId + } + } + else -> continue } } @@ -40,6 +66,13 @@ private object ShortUrlServiceImpl : ShortUrlService { override suspend fun findShortenedUrl(shortenedId: String): String? = newSuspendedTransaction { ShortUrl.findById(shortenedId)?.url } + + override suspend fun deleteExpiredUrls() { + val now = Clock.System.now() + newSuspendedTransaction { + ShortUrls.deleteWhere { (expirationAt.isNotNull()) and (expirationAt.lessEq(now)) } + } + } } fun String.normalizeAsUrl(): String? = try { diff --git a/settings.gradle.kts b/settings.gradle.kts index d57ce24..e9ecb78 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") maven("https://maven.pkg.jetbrains.space/kotlin/p/wasm/experimental") + mavenLocal() } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 745bf75..323f307 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.ktor.shared.resources) + implementation(libs.kotlinx.datetime) } } } diff --git a/shared/src/commonMain/kotlin/Resources.kt b/shared/src/commonMain/kotlin/Resources.kt index c0446c4..e8543c6 100644 --- a/shared/src/commonMain/kotlin/Resources.kt +++ b/shared/src/commonMain/kotlin/Resources.kt @@ -1,7 +1,11 @@ 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)