Skip to content

Commit

Permalink
KTOR-7620 Make Url class @serializable and JVM Serializable
Browse files Browse the repository at this point in the history
In our project we had to define our own UrlSerializer. It would be much nicer to have this in the Ktor library itself, so it works out of the box (similar to how Cookie was recently extended).

Also, types like Url and Cookie should be java.io.Serializable. Otherwise Android crashes when using those types as e.g. screen arguments. This happens very quickly when Url is used indirectly as part of a data class where we wanted type safety.
  • Loading branch information
wkornewald committed Oct 27, 2024
1 parent 3d71a28 commit 8db09e6
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 3 deletions.
7 changes: 7 additions & 0 deletions ktor-http/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,12 @@ kotlin {
api(libs.kotlinx.serialization.core)
}
}
jvmTest {
dependencies {
implementation(project(":ktor-shared:ktor-junit"))
implementation(project(":ktor-shared:ktor-serialization:ktor-serialization-kotlinx"))
implementation(project(":ktor-shared:ktor-serialization:ktor-serialization-kotlinx:ktor-serialization-kotlinx-json"))
}
}
}
}
13 changes: 12 additions & 1 deletion ktor-http/common/src/io/ktor/http/Cookie.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package io.ktor.http

import io.ktor.util.*
import io.ktor.util.date.*
import io.ktor.utils.io.*
import kotlinx.serialization.*
import kotlin.jvm.*

Expand Down Expand Up @@ -37,7 +38,17 @@ public data class Cookie(
val secure: Boolean = false,
val httpOnly: Boolean = false,
val extensions: Map<String, String?> = emptyMap()
)
) : JvmSerializable {
private fun writeReplace(): Any = JvmSerializerReplacement(CookieJvmSerializer, this)
}

internal object CookieJvmSerializer : JvmSerializer<Cookie> {
override fun jvmSerialize(value: Cookie): ByteArray =
renderSetCookieHeader(value).encodeToByteArray()

override fun jvmDeserialize(value: ByteArray): Cookie =
parseServerSetCookieHeader(value.decodeToString())
}

/**
* Cooke encoding strategy
Expand Down
3 changes: 2 additions & 1 deletion ktor-http/common/src/io/ktor/http/URLProtocol.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
package io.ktor.http

import io.ktor.util.*
import io.ktor.utils.io.*

/**
* Represents URL protocol
* @property name of protocol (schema)
* @property defaultPort default port for protocol or `-1` if not known
*/
public data class URLProtocol(val name: String, val defaultPort: Int) {
public data class URLProtocol(val name: String, val defaultPort: Int) : JvmSerializable {
init {
require(name.all { it.isLowerCase() }) { "All characters should be lower case" }
}
Expand Down
29 changes: 28 additions & 1 deletion ktor-http/common/src/io/ktor/http/Url.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

package io.ktor.http

import io.ktor.utils.io.*
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*

/**
* Represents an immutable URL
*
Expand All @@ -18,6 +23,7 @@ package io.ktor.http
* @property password password part of URL
* @property trailingQuery keep trailing question character even if there are no query parameters
*/
@Serializable(with = UrlSerializer::class)
public class Url internal constructor(
protocol: URLProtocol?,
public val host: String,
Expand All @@ -29,7 +35,7 @@ public class Url internal constructor(
public val password: String?,
public val trailingQuery: Boolean,
private val urlString: String
) {
) : JvmSerializable {
init {
require(specifiedPort in 0..65535) {
"Port must be between 0 and 65535, or $DEFAULT_PORT if not set. Provided: $specifiedPort"
Expand Down Expand Up @@ -222,6 +228,8 @@ public class Url internal constructor(
return urlString.hashCode()
}

private fun writeReplace(): Any = JvmSerializerReplacement(UrlJvmSerializer, this)

public companion object
}

Expand Down Expand Up @@ -254,3 +262,22 @@ internal val Url.encodedUserAndPassword: String
get() = buildString {
appendUserAndPassword(encodedUser, encodedPassword)
}

public class UrlSerializer : KSerializer<Url> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Url", PrimitiveKind.STRING)

override fun deserialize(decoder: Decoder): Url =
Url(decoder.decodeString())

override fun serialize(encoder: Encoder, value: Url) {
encoder.encodeString(value.toString())
}
}

internal object UrlJvmSerializer : JvmSerializer<Url> {
override fun jvmSerialize(value: Url): ByteArray =
value.toString().encodeToByteArray()

override fun jvmDeserialize(value: ByteArray): Url =
Url(value.decodeToString())
}
24 changes: 24 additions & 0 deletions ktor-http/jvm/test/io/ktor/tests/http/SerializableTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// ktlint-disable filename
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.tests.http

import io.ktor.http.*
import io.ktor.junit.*
import kotlin.test.*

class SerializableTest {
@Test
fun urlTest() {
val url = Url("https://localhost/path?key=value#fragment")
assertEquals(url, assertSerializable(url))
}

@Test
fun cookieTest() {
val cookie = Cookie("key", "value")
assertEquals(cookie, assertSerializable(cookie))
}
}
16 changes: 16 additions & 0 deletions ktor-io/common/src/io/ktor/utils/io/JvmSerializable.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.utils.io

public expect interface JvmSerializable

public interface JvmSerializer<T> : JvmSerializable {
public fun jvmSerialize(value: T): ByteArray
public fun jvmDeserialize(value: ByteArray): T
}

public expect fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any

internal object DummyJvmSimpleSerializerReplacement
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.utils.io

/** Alias for `java.io.Serializable` on JVM. Empty interface otherwise. */
public actual interface JvmSerializable

public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
DummyJvmSimpleSerializerReplacement
39 changes: 39 additions & 0 deletions ktor-io/jvm/src/io/ktor/utils/io/JvmSerializable.jvm.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.utils.io

import java.io.*

public actual typealias JvmSerializable = Serializable

@Suppress("UNCHECKED_CAST")
public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
DefaultJvmSerializerReplacement(serializer, value)

@PublishedApi // IMPORTANT: changing the class name would result in serialization incompatibility
internal class DefaultJvmSerializerReplacement<T : Any>(
private var serializer: JvmSerializer<T>?,
private var value: T?
) : Externalizable {
constructor() : this(null, null)

override fun writeExternal(out: ObjectOutput) {
out.writeObject(serializer)
out.writeObject(serializer!!.jvmSerialize(value!!))
}

@Suppress("UNCHECKED_CAST")
override fun readExternal(`in`: ObjectInput) {
serializer = `in`.readObject() as JvmSerializer<T>
value = serializer!!.jvmDeserialize(`in`.readObject() as ByteArray)
}

private fun readResolve(): Any =
value!!

companion object {
private const val serialVersionUID: Long = 0L
}
}
11 changes: 11 additions & 0 deletions ktor-io/posix/src/io/ktor/utils/io/JvmSerializable.posix.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.utils.io

/** Alias for `java.io.Serializable` on JVM. Empty interface otherwise. */
public actual interface JvmSerializable

public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
DummyJvmSimpleSerializerReplacement
9 changes: 9 additions & 0 deletions ktor-shared/ktor-junit/jvm/src/io/ktor/junit/Assertions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

package io.ktor.junit

import java.io.*

/**
* Convenience function for asserting on all elements of a collection.
*/
Expand All @@ -29,3 +31,10 @@ fun <T> assertAll(collection: Iterable<T>, assertion: (T) -> Unit) {
}
)
}

inline fun <reified T : Any> assertSerializable(obj: T): T {
val encoded = ByteArrayOutputStream().also {
ObjectOutputStream(it).writeObject(obj)
}.toByteArray()
return ObjectInputStream(encoded.inputStream()).readObject() as T
}

0 comments on commit 8db09e6

Please sign in to comment.