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
@@ -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)
}