Skip to content

Commit 62e380e

Browse files
committed
KTOR-7620 Make Url class @serializable and JVM Serializable
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.
1 parent 3d71a28 commit 62e380e

File tree

11 files changed

+170
-3
lines changed

11 files changed

+170
-3
lines changed

ktor-http/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,12 @@ kotlin {
1414
api(libs.kotlinx.serialization.core)
1515
}
1616
}
17+
jvmTest {
18+
dependencies {
19+
implementation(project(":ktor-shared:ktor-junit"))
20+
implementation(project(":ktor-shared:ktor-serialization:ktor-serialization-kotlinx"))
21+
implementation(project(":ktor-shared:ktor-serialization:ktor-serialization-kotlinx:ktor-serialization-kotlinx-json"))
22+
}
23+
}
1724
}
1825
}

ktor-http/common/src/io/ktor/http/Cookie.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package io.ktor.http
66

77
import io.ktor.util.*
88
import io.ktor.util.date.*
9+
import io.ktor.utils.io.*
910
import kotlinx.serialization.*
1011
import kotlin.jvm.*
1112

@@ -37,7 +38,17 @@ public data class Cookie(
3738
val secure: Boolean = false,
3839
val httpOnly: Boolean = false,
3940
val extensions: Map<String, String?> = emptyMap()
40-
)
41+
) : JvmSerializable {
42+
private fun writeReplace(): Any = JvmSerializerReplacement(CookieJvmSerializer, this)
43+
}
44+
45+
internal object CookieJvmSerializer : JvmSerializer<Cookie> {
46+
override fun jvmSerialize(value: Cookie): ByteArray =
47+
renderSetCookieHeader(value).encodeToByteArray()
48+
49+
override fun jvmDeserialize(value: ByteArray): Cookie =
50+
parseServerSetCookieHeader(value.decodeToString())
51+
}
4152

4253
/**
4354
* Cooke encoding strategy

ktor-http/common/src/io/ktor/http/URLProtocol.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
package io.ktor.http
66

77
import io.ktor.util.*
8+
import io.ktor.utils.io.*
89

910
/**
1011
* Represents URL protocol
1112
* @property name of protocol (schema)
1213
* @property defaultPort default port for protocol or `-1` if not known
1314
*/
14-
public data class URLProtocol(val name: String, val defaultPort: Int) {
15+
public data class URLProtocol(val name: String, val defaultPort: Int) : JvmSerializable {
1516
init {
1617
require(name.all { it.isLowerCase() }) { "All characters should be lower case" }
1718
}

ktor-http/common/src/io/ktor/http/Url.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
package io.ktor.http
66

7+
import io.ktor.utils.io.*
8+
import kotlinx.serialization.*
9+
import kotlinx.serialization.descriptors.*
10+
import kotlinx.serialization.encoding.*
11+
712
/**
813
* Represents an immutable URL
914
*
@@ -18,6 +23,7 @@ package io.ktor.http
1823
* @property password password part of URL
1924
* @property trailingQuery keep trailing question character even if there are no query parameters
2025
*/
26+
@Serializable(with = UrlSerializer::class)
2127
public class Url internal constructor(
2228
protocol: URLProtocol?,
2329
public val host: String,
@@ -29,7 +35,7 @@ public class Url internal constructor(
2935
public val password: String?,
3036
public val trailingQuery: Boolean,
3137
private val urlString: String
32-
) {
38+
) : JvmSerializable {
3339
init {
3440
require(specifiedPort in 0..65535) {
3541
"Port must be between 0 and 65535, or $DEFAULT_PORT if not set. Provided: $specifiedPort"
@@ -222,9 +228,19 @@ public class Url internal constructor(
222228
return urlString.hashCode()
223229
}
224230

231+
private fun writeReplace(): Any = JvmSerializerReplacement(UrlJvmSerializer, this)
232+
225233
public companion object
226234
}
227235

236+
internal object UrlJvmSerializer : JvmSerializer<Url> {
237+
override fun jvmSerialize(value: Url): ByteArray =
238+
value.toString().encodeToByteArray()
239+
240+
override fun jvmDeserialize(value: ByteArray): Url =
241+
Url(value.decodeToString())
242+
}
243+
228244
/**
229245
* [Url] authority.
230246
*/
@@ -254,3 +270,14 @@ internal val Url.encodedUserAndPassword: String
254270
get() = buildString {
255271
appendUserAndPassword(encodedUser, encodedPassword)
256272
}
273+
274+
public class UrlSerializer : KSerializer<Url> {
275+
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Url", PrimitiveKind.STRING)
276+
277+
override fun deserialize(decoder: Decoder): Url =
278+
Url(decoder.decodeString())
279+
280+
override fun serialize(encoder: Encoder, value: Url) {
281+
encoder.encodeString(value.toString())
282+
}
283+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// ktlint-disable filename
2+
/*
3+
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
4+
*/
5+
6+
package io.ktor.tests.http
7+
8+
import io.ktor.http.*
9+
import io.ktor.junit.*
10+
import kotlin.test.*
11+
12+
class SerializableTest {
13+
@Test
14+
fun urlTest() {
15+
val url = Url("https://localhost/path?key=value#fragment")
16+
assertEquals(url, assertSerializable(url))
17+
}
18+
19+
@Test
20+
fun cookieTest() {
21+
val cookie = Cookie("key", "value")
22+
assertEquals(cookie, assertSerializable(cookie))
23+
}
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.utils.io
6+
7+
public expect interface JvmSerializable
8+
9+
public interface JvmSerializer<T> : JvmSerializable {
10+
public fun jvmSerialize(value: T): ByteArray
11+
public fun jvmDeserialize(value: ByteArray): T
12+
}
13+
14+
public expect fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any
15+
16+
internal object DummyJvmSimpleSerializerReplacement
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.utils.io
6+
7+
/** Alias for `java.io.Serializable` on JVM. Empty interface otherwise. */
8+
public actual interface JvmSerializable
9+
10+
public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
11+
DummyJvmSimpleSerializerReplacement
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.utils.io
6+
7+
import java.io.*
8+
9+
public actual typealias JvmSerializable = Serializable
10+
11+
@Suppress("UNCHECKED_CAST")
12+
public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
13+
DefaultJvmSerializerReplacement(serializer, value)
14+
15+
@PublishedApi // IMPORTANT: changing the class name would result in serialization incompatibility
16+
internal class DefaultJvmSerializerReplacement<T : Any>(
17+
private var serializer: JvmSerializer<T>?,
18+
private var value: T?
19+
) : Externalizable {
20+
constructor() : this(null, null)
21+
22+
override fun writeExternal(out: ObjectOutput) {
23+
out.writeObject(serializer)
24+
out.writeObject(serializer!!.jvmSerialize(value!!))
25+
}
26+
27+
@Suppress("UNCHECKED_CAST")
28+
override fun readExternal(`in`: ObjectInput) {
29+
serializer = `in`.readObject() as JvmSerializer<T>
30+
value = serializer!!.jvmDeserialize(`in`.readObject() as ByteArray)
31+
}
32+
33+
private fun readResolve(): Any =
34+
value!!
35+
36+
companion object {
37+
private const val serialVersionUID: Long = 0L
38+
}
39+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.utils.io
6+
7+
/** Alias for `java.io.Serializable` on JVM. Empty interface otherwise. */
8+
public actual interface JvmSerializable
9+
10+
public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
11+
DummyJvmSimpleSerializerReplacement
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.utils.io
6+
7+
/** Alias for `java.io.Serializable` on JVM. Empty interface otherwise. */
8+
public actual interface JvmSerializable
9+
10+
public actual fun <T : Any> JvmSerializerReplacement(serializer: JvmSerializer<T>, value: T): Any =
11+
DummyJvmSimpleSerializerReplacement

ktor-shared/ktor-junit/jvm/src/io/ktor/junit/Assertions.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
package io.ktor.junit
66

7+
import java.io.*
8+
79
/**
810
* Convenience function for asserting on all elements of a collection.
911
*/
@@ -29,3 +31,10 @@ fun <T> assertAll(collection: Iterable<T>, assertion: (T) -> Unit) {
2931
}
3032
)
3133
}
34+
35+
inline fun <reified T : Any> assertSerializable(obj: T): T {
36+
val encoded = ByteArrayOutputStream().also {
37+
ObjectOutputStream(it).writeObject(obj)
38+
}.toByteArray()
39+
return ObjectInputStream(encoded.inputStream()).readObject() as T
40+
}

0 commit comments

Comments
 (0)