diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index dc72aa6a83..27eeeacb38 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -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"))) } diff --git a/core/network/src/main/java/org/signal/network/NetworkResult.kt b/core/network/src/main/java/org/signal/network/NetworkResult.kt index 1aaf8caed8..689a4b60d7 100644 --- a/core/network/src/main/java/org/signal/network/NetworkResult.kt +++ b/core/network/src/main/java/org/signal/network/NetworkResult.kt @@ -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( 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 fromWebSocket(deserializer: DeserializationStrategy, fetcher: Fetcher>): NetworkResult { + 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( } } + /** + * Parses the error body with the provided kotlinx.serialization [deserializer], returning null if there's no body + * or it can't be parsed. + */ + fun parseJsonBody(deserializer: DeserializationStrategy): 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( } /** - * 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( else -> Success(JsonUtil.fromJson(this.body, responseJsonClass.java)) } } + + fun WebsocketResponse.toSuccess(deserializer: DeserializationStrategy): NetworkResult { + return Success(SignalJson.json.decodeFromString(deserializer, this.body)) + } } - class DefaultWebSocketConverter(private val responseJsonClass: KClass) : WebSocketResponseConverter { + /** + * Converts any 2xx response body into [T]. + */ + class DefaultWebSocketConverter private constructor( + private val responseJsonClass: KClass?, + private val deserializer: DeserializationStrategy? + ) : WebSocketResponseConverter { + constructor(responseJsonClass: KClass) : this(responseJsonClass, null) + constructor(deserializer: DeserializationStrategy) : this(null, deserializer) + override fun convert(response: WebsocketResponse): NetworkResult { 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(private val responseJsonClass: KClass) : WebSocketResponseConverter { + /** + * Like [DefaultWebSocketConverter], but treats a 204 as an error rather than an empty success. + */ + class LongPollingWebSocketConverter private constructor( + private val responseJsonClass: KClass?, + private val deserializer: DeserializationStrategy? + ) : WebSocketResponseConverter { + constructor(responseJsonClass: KClass) : this(responseJsonClass, null) + constructor(deserializer: DeserializationStrategy) : this(null, deserializer) + override fun convert(response: WebsocketResponse): NetworkResult { 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!!) } } } diff --git a/core/network/src/main/java/org/signal/network/websocket/WebSocketRequestExt.kt b/core/network/src/main/java/org/signal/network/websocket/WebSocketRequestExt.kt index 1ed5f216fe..9d391c10d0 100644 --- a/core/network/src/main/java/org/signal/network/websocket/WebSocketRequestExt.kt +++ b/core/network/src/main/java/org/signal/network/websocket/WebSocketRequestExt.kt @@ -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 WebSocketRequestMessage.Companion.post(path: String, body: T, serializer: SerializationStrategy, headers: Map = 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 WebSocketRequestMessage.Companion.put(path: String, body: T, serializer: SerializationStrategy, headers: Map = 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.toHeaderList(): List { return map { (key, value) -> "$key:$value" } } + +private fun T.toJsonByteString(serializer: SerializationStrategy): ByteString { + return SignalJson.json.encodeToString(serializer, this).toByteArray().toByteString() +} diff --git a/core/network/src/test/java/org/signal/network/KotlinxSerializationPathTest.kt b/core/network/src/test/java/org/signal/network/KotlinxSerializationPathTest.kt new file mode 100644 index 0000000000..47f700de76 --- /dev/null +++ b/core/network/src/test/java/org/signal/network/KotlinxSerializationPathTest.kt @@ -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(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(NonSuccessfulResponseCodeException(409, "", "not json")) + + assertThat(error.parseJsonBody(Model.serializer())).isNull() + } + + @Test + fun `parseJsonBody returns null when there is no body`() { + val error = NetworkResult.StatusCodeError(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 + ) +} diff --git a/core/serialization/build.gradle.kts b/core/serialization/build.gradle.kts index 50b551ac73..972dceb633 100644 --- a/core/serialization/build.gradle.kts +++ b/core/serialization/build.gradle.kts @@ -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) } diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToBase64NoPaddingSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToBase64NoPaddingSerializer.kt new file mode 100644 index 0000000000..e194f3425a --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToBase64NoPaddingSerializer.kt @@ -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 { + 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)) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ECPublicKeyToBase64NoPaddingSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ECPublicKeyToBase64NoPaddingSerializer.kt new file mode 100644 index 0000000000..e794146953 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ECPublicKeyToBase64NoPaddingSerializer.kt @@ -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 { + 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())) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/IdentityKeyToBase64NoPaddingSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/IdentityKeyToBase64NoPaddingSerializer.kt new file mode 100644 index 0000000000..7ae26f0caf --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/IdentityKeyToBase64NoPaddingSerializer.kt @@ -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 { + 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())) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/KEMPublicKeyToBase64NoPaddingSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/KEMPublicKeyToBase64NoPaddingSerializer.kt new file mode 100644 index 0000000000..2c72cbd4a9 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/KEMPublicKeyToBase64NoPaddingSerializer.kt @@ -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 { + 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())) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/MasterKeyToBase64Serializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/MasterKeyToBase64Serializer.kt new file mode 100644 index 0000000000..387b5ea397 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/MasterKeyToBase64Serializer.kt @@ -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 { + 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())) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/PniSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/PniSerializer.kt new file mode 100644 index 0000000000..6e76232437 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/PniSerializer.kt @@ -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 { + 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()) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ServiceIdSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ServiceIdSerializer.kt new file mode 100644 index 0000000000..a8d83bd148 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ServiceIdSerializer.kt @@ -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 { + 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()) + } +} diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/SignalJson.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/SignalJson.kt index 6ad2913c3d..51854f6199 100644 --- a/core/serialization/src/main/java/org/signal/core/util/serialization/SignalJson.kt +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/SignalJson.kt @@ -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 encode(input: T): Either = either { try { diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/UuidSerializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/UuidSerializer.kt new file mode 100644 index 0000000000..8ddbbd509a --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/UuidSerializer.kt @@ -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 { + 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()) + } +} diff --git a/core/serialization/src/testFixtures/java/org/signal/core/util/serialization/testutil/JsonGolden.kt b/core/serialization/src/testFixtures/java/org/signal/core/util/serialization/testutil/JsonGolden.kt new file mode 100644 index 0000000000..1366af2ae2 --- /dev/null +++ b/core/serialization/src/testFixtures/java/org/signal/core/util/serialization/testutil/JsonGolden.kt @@ -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 assertMatchesGolden(serializer: KSerializer, 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) +} diff --git a/core/serialization/src/testFixtures/java/org/signal/core/util/serialization/testutil/LegacyJacksonJson.kt b/core/serialization/src/testFixtures/java/org/signal/core/util/serialization/testutil/LegacyJacksonJson.kt new file mode 100644 index 0000000000..9f0bb23197 --- /dev/null +++ b/core/serialization/src/testFixtures/java/org/signal/core/util/serialization/testutil/LegacyJacksonJson.kt @@ -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) +} diff --git a/lib/libsignal-service/build.gradle.kts b/lib/libsignal-service/build.gradle.kts index 735228e989..e94f7e8fbe 100644 --- a/lib/libsignal-service/build.gradle.kts +++ b/lib/libsignal-service/build.gradle.kts @@ -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) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResultExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResultExtensions.kt index 6a136462a4..17130d7e35 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResultExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResultExtensions.kt @@ -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 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 NetworkResult.Companion.fromWebSocketRequest( + signalWebSocket: SignalWebSocket, + request: WebSocketRequestMessage, + deserializer: DeserializationStrategy, + timeout: Duration = WebSocketConnection.DEFAULT_SEND_TIMEOUT +): NetworkResult { + 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. diff --git a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt index 3e0e85711f..fb5af0836c 100644 --- a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt +++ b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt @@ -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 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 {