Add base kotlinx serialization tooling.

This commit is contained in:
Greyson Parrelli
2026-09-15 18:26:03 -04:00
parent 5d4e8eaf57
commit 2928d61c2a
19 changed files with 533 additions and 12 deletions
+5
View File
@@ -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
)
}
+6
View File
@@ -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)
}
@@ -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))
}
}
@@ -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()))
}
}
@@ -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()))
}
}
@@ -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()))
}
}
@@ -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())
}
}
@@ -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())
}
}
@@ -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)
}
@@ -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)
}
+4
View File
@@ -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)
@@ -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 {