mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add base kotlinx serialization tooling.
This commit is contained in:
@@ -8,6 +8,7 @@ import org.gradle.api.tasks.SourceSetContainer
|
||||
plugins {
|
||||
id("java-library")
|
||||
id("org.jetbrains.kotlin.jvm")
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
id("ktlint")
|
||||
id("com.squareup.wire")
|
||||
}
|
||||
@@ -69,6 +70,8 @@ dependencies {
|
||||
api(libs.square.okio)
|
||||
api(libs.square.okhttp3)
|
||||
|
||||
api(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.google.jsr305)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.coroutines.core.jvm)
|
||||
@@ -76,7 +79,9 @@ dependencies {
|
||||
|
||||
implementation(project(":core:util-jvm"))
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":core:serialization"))
|
||||
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.assertk)
|
||||
testImplementation(testFixtures(project(":core:serialization")))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
package org.signal.network
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import org.signal.core.util.concurrent.safeBlockingGet
|
||||
import org.signal.core.util.serialization.SignalJson
|
||||
import org.signal.network.NetworkResult.ApplicationError
|
||||
import org.signal.network.NetworkResult.StatusCodeError
|
||||
import org.signal.network.exceptions.MalformedRequestException
|
||||
@@ -68,6 +70,16 @@ sealed class NetworkResult<T>(
|
||||
return fromWebSocket(DefaultWebSocketConverter(T::class), fetcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience method to convert a websocket request into a network result, parsing the body into type [T] with
|
||||
* the provided kotlinx.serialization [deserializer].
|
||||
*
|
||||
* Common HTTP errors will be translated to [StatusCodeError]s.
|
||||
*/
|
||||
fun <T : Any> fromWebSocket(deserializer: DeserializationStrategy<T>, fetcher: Fetcher<Single<WebsocketResponse>>): NetworkResult<T> {
|
||||
return fromWebSocket(DefaultWebSocketConverter(deserializer), fetcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience method to convert a websocket request into a network result, using the provided
|
||||
* [webSocketResponseConverter] to parse the response into type [T].
|
||||
@@ -191,6 +203,15 @@ sealed class NetworkResult<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the error body with the provided kotlinx.serialization [deserializer], returning null if there's no body
|
||||
* or it can't be parsed.
|
||||
*/
|
||||
fun <T> parseJsonBody(deserializer: DeserializationStrategy<T>): T? {
|
||||
val body = stringBody ?: binaryBody?.decodeToString() ?: return null
|
||||
return runCatching { SignalJson.json.decodeFromString(deserializer, body) }.getOrNull()
|
||||
}
|
||||
|
||||
fun header(key: String): String? {
|
||||
return headers[key.lowercase()]
|
||||
}
|
||||
@@ -391,7 +412,7 @@ sealed class NetworkResult<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify an action to be run when a application error occurs. When a result is a [ApplicationErrorAction] or is transformed into one further down the chain via
|
||||
* Specify an action to be run when an application error occurs. When a result is a [ApplicationErrorAction] or is transformed into one further down the chain via
|
||||
* a future [map] or [then], this code will be run. There can only ever be a single application error in a chain, and therefore this lambda will only ever
|
||||
* be run a single time.
|
||||
*
|
||||
@@ -445,24 +466,50 @@ sealed class NetworkResult<T>(
|
||||
else -> Success(JsonUtil.fromJson(this.body, responseJsonClass.java))
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Any> WebsocketResponse.toSuccess(deserializer: DeserializationStrategy<T>): NetworkResult<T> {
|
||||
return Success(SignalJson.json.decodeFromString(deserializer, this.body))
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultWebSocketConverter<T : Any>(private val responseJsonClass: KClass<T>) : WebSocketResponseConverter<T> {
|
||||
/**
|
||||
* Converts any 2xx response body into [T].
|
||||
*/
|
||||
class DefaultWebSocketConverter<T : Any> private constructor(
|
||||
private val responseJsonClass: KClass<T>?,
|
||||
private val deserializer: DeserializationStrategy<T>?
|
||||
) : WebSocketResponseConverter<T> {
|
||||
constructor(responseJsonClass: KClass<T>) : this(responseJsonClass, null)
|
||||
constructor(deserializer: DeserializationStrategy<T>) : this(null, deserializer)
|
||||
|
||||
override fun convert(response: WebsocketResponse): NetworkResult<T> {
|
||||
return if (response.status < 200 || response.status > 299) {
|
||||
response.toStatusCodeError()
|
||||
} else if (deserializer != null) {
|
||||
response.toSuccess(deserializer)
|
||||
} else {
|
||||
response.toSuccess(responseJsonClass)
|
||||
response.toSuccess(responseJsonClass!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LongPollingWebSocketConverter<T : Any>(private val responseJsonClass: KClass<T>) : WebSocketResponseConverter<T> {
|
||||
/**
|
||||
* Like [DefaultWebSocketConverter], but treats a 204 as an error rather than an empty success.
|
||||
*/
|
||||
class LongPollingWebSocketConverter<T : Any> private constructor(
|
||||
private val responseJsonClass: KClass<T>?,
|
||||
private val deserializer: DeserializationStrategy<T>?
|
||||
) : WebSocketResponseConverter<T> {
|
||||
constructor(responseJsonClass: KClass<T>) : this(responseJsonClass, null)
|
||||
constructor(deserializer: DeserializationStrategy<T>) : this(null, deserializer)
|
||||
|
||||
override fun convert(response: WebsocketResponse): NetworkResult<T> {
|
||||
return if (response.status == 204 || response.status < 200 || response.status > 299) {
|
||||
response.toStatusCodeError()
|
||||
} else if (deserializer != null) {
|
||||
response.toSuccess(deserializer)
|
||||
} else {
|
||||
response.toSuccess(responseJsonClass)
|
||||
response.toSuccess(responseJsonClass!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
package org.signal.network.websocket
|
||||
|
||||
import kotlinx.serialization.SerializationStrategy
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.serialization.SignalJson
|
||||
import org.signal.network.util.JsonUtil
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import java.security.SecureRandom
|
||||
@@ -35,6 +38,20 @@ fun WebSocketRequestMessage.Companion.post(path: String, body: Any?, headers: Ma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic POST web socket request, where the body is JSON-ified with the provided kotlinx.serialization
|
||||
* [serializer].
|
||||
*/
|
||||
fun <T> WebSocketRequestMessage.Companion.post(path: String, body: T, serializer: SerializationStrategy<T>, headers: Map<String, String> = emptyMap()): WebSocketRequestMessage {
|
||||
return WebSocketRequestMessage(
|
||||
verb = "POST",
|
||||
path = path,
|
||||
body = body.toJsonByteString(serializer),
|
||||
headers = listOf("content-type:application/json") + headers.toHeaderList(),
|
||||
id = SecureRandom().nextLong()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic DELETE web socket request
|
||||
*/
|
||||
@@ -63,6 +80,20 @@ fun WebSocketRequestMessage.Companion.put(path: String, body: Any, headers: Map<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic PUT web socket request, where the body is JSON-ified with the provided kotlinx.serialization
|
||||
* [serializer].
|
||||
*/
|
||||
fun <T> WebSocketRequestMessage.Companion.put(path: String, body: T, serializer: SerializationStrategy<T>, headers: Map<String, String> = emptyMap()): WebSocketRequestMessage {
|
||||
return WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = path,
|
||||
headers = listOf("content-type:application/json") + headers.toHeaderList(),
|
||||
body = body.toJsonByteString(serializer),
|
||||
id = SecureRandom().nextLong()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom PUT web socket request, where body and content type header are provided by caller.
|
||||
*/
|
||||
@@ -79,3 +110,7 @@ fun WebSocketRequestMessage.Companion.putCustom(path: String, body: ByteArray, h
|
||||
private fun Map<String, String>.toHeaderList(): List<String> {
|
||||
return map { (key, value) -> "$key:$value" }
|
||||
}
|
||||
|
||||
private fun <T> T.toJsonByteString(serializer: SerializationStrategy<T>): ByteString {
|
||||
return SignalJson.json.encodeToString(serializer, this).toByteArray().toByteString()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNull
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.serialization.testutil.JsonGolden
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.signal.network.websocket.post
|
||||
import org.signal.network.websocket.put
|
||||
|
||||
/**
|
||||
* Covers the kotlinx.serialization paths added alongside the Jackson ones, so models can be migrated one at a time.
|
||||
*/
|
||||
class KotlinxSerializationPathTest {
|
||||
|
||||
@Test
|
||||
fun `fromWebSocket parses a success body`() {
|
||||
val result = NetworkResult.fromWebSocket(Model.serializer()) {
|
||||
Single.just(WebsocketResponse(200, """{"name":"Alice","device_id":2}""", emptyMap(), false))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(NetworkResult.Success::class)
|
||||
assertThat((result as NetworkResult.Success).result).isEqualTo(Model("Alice", 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromWebSocket turns a non-2xx into a status code error`() {
|
||||
val result = NetworkResult.fromWebSocket(Model.serializer()) {
|
||||
Single.just(WebsocketResponse(409, """{"name":"Alice","device_id":2}""", emptyMap(), false))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(NetworkResult.StatusCodeError::class)
|
||||
assertThat((result as NetworkResult.StatusCodeError).code).isEqualTo(409)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `longPolling converter treats 204 as an error`() {
|
||||
val converter = NetworkResult.LongPollingWebSocketConverter(Model.serializer())
|
||||
val result = converter.convert(WebsocketResponse(204, "", emptyMap(), false))
|
||||
|
||||
assertThat(result).isInstanceOf(NetworkResult.StatusCodeError::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseJsonBody reads the error body`() {
|
||||
val error = NetworkResult.StatusCodeError<Unit>(NonSuccessfulResponseCodeException(409, "", """{"name":"Alice","device_id":2}"""))
|
||||
|
||||
assertThat(error.parseJsonBody(Model.serializer())).isEqualTo(Model("Alice", 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseJsonBody returns null for an unparseable body`() {
|
||||
val error = NetworkResult.StatusCodeError<Unit>(NonSuccessfulResponseCodeException(409, "", "not json"))
|
||||
|
||||
assertThat(error.parseJsonBody(Model.serializer())).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseJsonBody returns null when there is no body`() {
|
||||
val error = NetworkResult.StatusCodeError<Unit>(NonSuccessfulResponseCodeException(409, ""))
|
||||
|
||||
assertThat(error.parseJsonBody(Model.serializer())).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `post serializes the body and sets the content type`() {
|
||||
val request = WebSocketRequestMessage.post("/v1/thing", Model("Alice", 2), Model.serializer())
|
||||
|
||||
assertThat(request.verb).isEqualTo("POST")
|
||||
assertThat(request.headers).isEqualTo(listOf("content-type:application/json"))
|
||||
JsonGolden.assertJsonEquals("""{"name":"Alice","device_id":2}""", request.body!!.utf8())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put serializes the body and sets the content type`() {
|
||||
val request = WebSocketRequestMessage.put("/v1/thing", Model("Alice", 2), Model.serializer())
|
||||
|
||||
assertThat(request.verb).isEqualTo("PUT")
|
||||
assertThat(request.headers).isEqualTo(listOf("content-type:application/json"))
|
||||
JsonGolden.assertJsonEquals("""{"name":"Alice","device_id":2}""", request.body!!.utf8())
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class Model(
|
||||
val name: String,
|
||||
@SerialName("device_id") val deviceId: Int
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ plugins {
|
||||
id("java-library")
|
||||
id("org.jetbrains.kotlin.jvm")
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
id("java-test-fixtures")
|
||||
id("ktlint")
|
||||
}
|
||||
|
||||
@@ -28,4 +29,9 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.libsignal.client)
|
||||
api(libs.arrow.core)
|
||||
|
||||
testFixturesImplementation(libs.kotlinx.serialization.json)
|
||||
testFixturesImplementation(libs.jackson.core)
|
||||
testFixturesImplementation(libs.jackson.module.kotlin)
|
||||
testFixturesImplementation(testLibs.assertk)
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.util.Base64
|
||||
|
||||
class ByteArrayToBase64NoPaddingSerializer : KSerializer<ByteArray> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): ByteArray {
|
||||
return Base64.decode(decoder.decodeString())
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ByteArray) {
|
||||
encoder.encodeString(Base64.encodeWithoutPadding(value))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
|
||||
class ECPublicKeyToBase64NoPaddingSerializer : KSerializer<ECPublicKey> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ECPublicKey", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): ECPublicKey {
|
||||
return ECPublicKey(Base64.decode(decoder.decodeString()))
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ECPublicKey) {
|
||||
encoder.encodeString(Base64.encodeWithoutPadding(value.serialize()))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
|
||||
class IdentityKeyToBase64NoPaddingSerializer : KSerializer<IdentityKey> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("IdentityKey", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): IdentityKey {
|
||||
return IdentityKey(Base64.decode(decoder.decodeString()), 0)
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: IdentityKey) {
|
||||
encoder.encodeString(Base64.encodeWithoutPadding(value.serialize()))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.protocol.kem.KEMPublicKey
|
||||
|
||||
class KEMPublicKeyToBase64NoPaddingSerializer : KSerializer<KEMPublicKey> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KEMPublicKey", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): KEMPublicKey {
|
||||
return KEMPublicKey(Base64.decode(decoder.decodeString()))
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: KEMPublicKey) {
|
||||
encoder.encodeString(Base64.encodeWithoutPadding(value.serialize()))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.Base64
|
||||
|
||||
class MasterKeyToBase64Serializer : KSerializer<MasterKey> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("MasterKey", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): MasterKey {
|
||||
return MasterKey(Base64.decode(decoder.decodeString()))
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: MasterKey) {
|
||||
encoder.encodeString(Base64.encodeWithPadding(value.serialize()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
|
||||
class PniSerializer : KSerializer<PNI> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PNI", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): PNI {
|
||||
return PNI.parseOrThrow(decoder.decodeString())
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: PNI) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.models.ServiceId
|
||||
|
||||
class ServiceIdSerializer : KSerializer<ServiceId> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ServiceId", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): ServiceId {
|
||||
return ServiceId.parseOrThrow(decoder.decodeString())
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ServiceId) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,18 @@ import kotlinx.serialization.json.Json
|
||||
*/
|
||||
object SignalJson {
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
/**
|
||||
* The JSON instance to use by default.
|
||||
*/
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true // If the service adds a field we don't track in our data model, ignore it during parsing instead of throwing an exception. I have no idea why this isn't the default.
|
||||
encodeDefaults = true // If we have a default value for an arg in the constructor, always include it in the JSON output, even if we don't set it explicitly.
|
||||
}
|
||||
|
||||
/**
|
||||
* [json], but null-valued properties are omitted entirely rather than written as `null`. Useful for specific endpoints.
|
||||
*/
|
||||
val jsonOmitNulls = Json(json) { explicitNulls = false }
|
||||
|
||||
inline fun <reified T> encode(input: T): Either<EncodeError, String> = either {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.signal.core.util.UuidUtil
|
||||
import java.util.UUID
|
||||
|
||||
class UuidSerializer : KSerializer<UUID> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): UUID {
|
||||
return UuidUtil.parseOrThrow(decoder.decodeString())
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: UUID) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization.testutil
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* Verifies that a model migrated from Jackson to kotlinx.serialization still produces and accepts the exact same JSON.
|
||||
*
|
||||
* The intended workflow when migrating a model is:
|
||||
* 1. Before touching the model, capture its current output via [LegacyJacksonJson] and paste the string into a test.
|
||||
* 2. Migrate the model to `@Serializable`.
|
||||
* 3. Assert with [assertMatchesGolden], which fails if either direction drifted.
|
||||
*/
|
||||
object JsonGolden {
|
||||
|
||||
/**
|
||||
* Asserts that [value] encodes to JSON equivalent to [golden], and that decoding [golden] round-trips back to the
|
||||
* same JSON. Object key order is ignored; everything else must match exactly.
|
||||
*/
|
||||
fun <T> assertMatchesGolden(serializer: KSerializer<T>, value: T, golden: String, json: Json) {
|
||||
val encoded = json.encodeToString(serializer, value)
|
||||
assertJsonEquals(golden, encoded, "Encoding does not match the golden JSON.")
|
||||
|
||||
val reEncoded = json.encodeToString(serializer, json.decodeFromString(serializer, golden))
|
||||
assertJsonEquals(golden, reEncoded, "Decoding the golden JSON and re-encoding it does not match.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that two JSON strings are structurally equal, ignoring object key order.
|
||||
*/
|
||||
fun assertJsonEquals(expected: String, actual: String, message: String = "JSON does not match.") {
|
||||
val expectedElement = Json.parseToJsonElement(expected)
|
||||
val actualElement = Json.parseToJsonElement(actual)
|
||||
|
||||
if (expectedElement != actualElement) {
|
||||
throw AssertionError("$message\n Expected: ${expectedElement.canonical()}\n Actual: ${actualElement.canonical()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement.canonical(): String = Json { prettyPrint = false }.encodeToString(JsonElement.serializer(), this)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.serialization.testutil
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
|
||||
/**
|
||||
* Replicas of the two legacy Jackson `ObjectMapper` configurations in the codebase, so that tests can capture the JSON
|
||||
* a model produced before it was migrated to kotlinx.serialization.
|
||||
*
|
||||
* These exist purely to support the Jackson removal and should be deleted along with `JsonUtils` and `JsonUtil`.
|
||||
*/
|
||||
object LegacyJacksonJson {
|
||||
|
||||
/** Mirrors `org.signal.core.util.JsonUtils`, which is used for locally persisted data. Note the enum handling. */
|
||||
val storageMapper: ObjectMapper = ObjectMapper().apply {
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING)
|
||||
enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING)
|
||||
registerKotlinModule()
|
||||
}
|
||||
|
||||
/** Mirrors `org.signal.network.util.JsonUtil`, which is used for network wire formats. */
|
||||
val networkMapper: ObjectMapper = ObjectMapper().apply {
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
registerKotlinModule()
|
||||
}
|
||||
|
||||
fun encodeForStorage(value: Any): String = storageMapper.writeValueAsString(value)
|
||||
|
||||
fun encodeForNetwork(value: Any): String = networkMapper.writeValueAsString(value)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ plugins {
|
||||
id("idea")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
id("com.squareup.wire")
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
java {
|
||||
@@ -111,17 +112,20 @@ dependencies {
|
||||
implementation(libs.rxjava3.rxkotlin)
|
||||
|
||||
implementation(libs.kotlin.stdlib.jdk8)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.coroutines.core.jvm)
|
||||
|
||||
api(project(":core:network"))
|
||||
implementation(project(":core:util-jvm"))
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":core:serialization"))
|
||||
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.assertk)
|
||||
testImplementation(testLibs.conscrypt.openjdk.uber)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testFixtures(project(":core:serialization")))
|
||||
|
||||
testFixturesImplementation(libs.libsignal.client)
|
||||
testFixturesImplementation(testLibs.junit.junit)
|
||||
|
||||
+20
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
@@ -45,6 +46,25 @@ fun <T : Any> NetworkResult.Companion.fromWebSocketRequest(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience method to convert a websocket request into a network result, parsing the response body with the
|
||||
* provided kotlinx.serialization [deserializer].
|
||||
* Common HTTP errors will be translated to [NetworkResult.StatusCodeError]s.
|
||||
*/
|
||||
fun <T : Any> NetworkResult.Companion.fromWebSocketRequest(
|
||||
signalWebSocket: SignalWebSocket,
|
||||
request: WebSocketRequestMessage,
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
timeout: Duration = WebSocketConnection.DEFAULT_SEND_TIMEOUT
|
||||
): NetworkResult<T> {
|
||||
return fromWebSocketRequest(
|
||||
signalWebSocket = signalWebSocket,
|
||||
request = request,
|
||||
timeout = timeout,
|
||||
webSocketResponseConverter = NetworkResult.DefaultWebSocketConverter(deserializer)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience method to convert a websocket request into a network result with the ability to fully customize the conversion of the response.
|
||||
* Common HTTP errors will be translated to [NetworkResult.StatusCodeError]s.
|
||||
|
||||
@@ -10,7 +10,6 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody
|
||||
@@ -61,10 +60,6 @@ class RegistrationApiV2(
|
||||
|
||||
/** Basic auth username for a fresh registration of an account that has no phone number. Must not parse as an e164 or a UUID. */
|
||||
private const val NO_NUMBER_AUTH_USERNAME = "no_number"
|
||||
|
||||
/** Drops null properties instead of emitting explicit nulls, for bodies where a field is meant to be absent entirely. */
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val JSON_OMITTING_NULLS = Json(SignalJson.json) { explicitNulls = false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -626,7 +621,7 @@ class RegistrationApiV2(
|
||||
}
|
||||
|
||||
private inline fun <reified T> T.toJsonRequestBodyOmittingNulls(): RequestBody {
|
||||
return JSON_OMITTING_NULLS.encodeToString(this).toRequestBody(APPLICATION_JSON)
|
||||
return SignalJson.jsonOmitNulls.encodeToString(this).toRequestBody(APPLICATION_JSON)
|
||||
}
|
||||
|
||||
private fun SignedPreKeyRecord.toSignedPreKeyEntity(): SignedPreKeyEntity {
|
||||
|
||||
Reference in New Issue
Block a user