Skip to content

Commit 7e4f5d6

Browse files
committed
[ECO-5386] Implemented unit tests for ObjectMesssage json/msgpack serialization
1. Created ObjectMessageFixtures to represent dummy data in various formats 2. Added jackson-param dependency to fix jackson deserialization issue 3. Marked javaParameters as true on compilerOptions
1 parent b846865 commit 7e4f5d6

7 files changed

Lines changed: 323 additions & 32 deletions

File tree

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ turbine = "1.2.0"
2626
ktor = "3.1.3"
2727
jetbrains-annoations = "26.0.2"
2828
jackson-msgpack = "0.8.11" # Compatible with msgpack-core 0.8.11
29+
jackson-param = "2.18.0" # Compatible with jackson-msgpack
2930

3031
[libraries]
3132
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
@@ -56,6 +57,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
5657
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
5758
jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" }
5859
jackson-msgpack = { group = "org.msgpack", name = "jackson-dataformat-msgpack", version.ref = "jackson-msgpack" }
60+
jackson-parameter-names = { group = "com.fasterxml.jackson.module", name = "jackson-module-parameter-names", version.ref = "jackson-param" }
5961

6062
[bundles]
6163
common = ["msgpack", "vcdiff-core"]

live-objects/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ dependencies {
1414
implementation(libs.bundles.common)
1515
implementation(libs.coroutine.core)
1616
implementation(libs.jackson.msgpack)
17+
implementation(libs.jackson.parameter.names) // Add this
18+
1719

1820
testImplementation(kotlin("test"))
1921
testImplementation(libs.bundles.kotlin.tests)
@@ -45,4 +47,12 @@ tasks.register<Test>("runLiveObjectIntegrationTests") {
4547

4648
kotlin {
4749
explicitApi()
50+
51+
/**
52+
* Enables Jackson to map JSON property names to constructor parameters without use of @JsonProperty.
53+
* Adds metadata params to bytecode class. Approach is completely binary-compatible with consumers of the library.
54+
*/
55+
compilerOptions {
56+
javaParameters = true
57+
}
4858
}

live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,22 @@ internal data class ObjectOperation(
214214
val nonce: String? = null,
215215

216216
/**
217-
* The initial value for the object, encoded as a JSON string.
218-
* This value should be used along with the nonce and timestamp to create the object ID.
219-
* Frontdoor will use this to verify the object ID. After verification, the value will be
220-
* decoded into the Map or Counter objects and the initialValue, nonce, and initialValueEncoding will be removed.
217+
* The initial value bytes for the object. These bytes should be used along with the nonce
218+
* and timestamp to create the object ID. Frontdoor will use this to verify the object ID.
219+
* After verification the bytes will be decoded into the Map or Counter objects and
220+
* the initialValue, nonce, and initialValueEncoding will be removed.
221221
* Spec: OOP3h
222222
*/
223-
val initialValue: String? = null,
223+
@JsonAdapter(InitialValueJsonSerializer::class)
224+
@JsonSerialize(using = InitialValueMsgpackSerializer::class)
225+
@JsonDeserialize(using = InitialValueMsgpackDeserializer::class)
226+
val initialValue: Binary? = null,
227+
228+
/** The initial value encoding defines how the initialValue should be interpreted.
229+
* Spec: OOP3i
230+
*/
231+
@Deprecated("Will be removed in the future, initialValue will be json string")
232+
val initialValueEncoding: ProtocolMessageFormat? = null
224233
)
225234

226235
/**

live-objects/src/main/kotlin/io/ably/lib/objects/Serialization.kt

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22

33
package io.ably.lib.objects
44

5+
import com.fasterxml.jackson.annotation.JsonCreator
6+
import com.fasterxml.jackson.annotation.JsonInclude
57
import com.fasterxml.jackson.core.JsonGenerator
68
import com.fasterxml.jackson.databind.DeserializationContext
79
import com.fasterxml.jackson.databind.ObjectMapper
810
import com.fasterxml.jackson.databind.SerializerProvider
11+
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule
912
import com.google.gson.*
1013
import org.msgpack.core.MessagePack
1114
import org.msgpack.core.MessagePacker
1215
import org.msgpack.core.MessageUnpacker
1316
import org.msgpack.jackson.dataformat.MessagePackFactory
17+
import org.msgpack.value.ImmutableMapValue
1418
import java.lang.reflect.Type
1519
import java.util.*
1620

@@ -19,7 +23,11 @@ internal val gson: Gson = GsonBuilder().create()
1923

2024
// Jackson ObjectMapper for MessagePack serialization (respects @JsonProperty annotations)
2125
// Caches type metadata and serializers for ObjectMessage class after first use, so next time it's super fast 🚀
22-
private val msgpackMapper = ObjectMapper(MessagePackFactory())
26+
// https://github.com/FasterXML/jackson-modules-java8/tree/3.x/parameter-names
27+
internal val msgpackMapper = ObjectMapper(MessagePackFactory()).apply {
28+
registerModule(ParameterNamesModule(JsonCreator.Mode.PROPERTIES))
29+
setSerializationInclusion(JsonInclude.Include.NON_NULL)
30+
}
2331

2432
internal fun ObjectMessage.toJsonObject(): JsonObject {
2533
return gson.toJsonTree(this).asJsonObject
@@ -30,29 +38,15 @@ internal fun JsonObject.toObjectMessage(): ObjectMessage {
3038
}
3139

3240
internal fun ObjectMessage.writeTo(packer: MessagePacker) {
33-
// Jackson automatically creates the correct msgpack map structure
34-
val msgpackBytes = msgpackMapper.writeValueAsBytes(this)
35-
36-
// Parse the msgpack bytes to get the structured value
37-
val tempUnpacker = MessagePack.newDefaultUnpacker(msgpackBytes)
38-
val msgpackValue = tempUnpacker.unpackValue()
39-
tempUnpacker.close()
40-
41-
// Pack the structured value using the provided packer
42-
packer.packValue(msgpackValue)
41+
val msgpackBytes = msgpackMapper.writeValueAsBytes(this) // returns correct msgpack map structure
42+
packer.writePayload(msgpackBytes)
4343
}
4444

45-
internal fun MessageUnpacker.readObjectMessage(): ObjectMessage {
46-
// Read the msgpack value from the unpacker
47-
val msgpackValue = this.unpackValue()
48-
49-
// Convert the msgpack value back to bytes
50-
val tempPacker = MessagePack.newDefaultBufferPacker()
51-
tempPacker.packValue(msgpackValue)
52-
val msgpackBytes = tempPacker.toByteArray()
53-
tempPacker.close()
54-
55-
// Let Jackson deserialize the msgpack bytes back to ObjectMessage
45+
internal fun ImmutableMapValue.toObjectMessage(): ObjectMessage {
46+
val msgpackBytes = MessagePack.newDefaultBufferPacker().use { packer ->
47+
packer.packValue(this)
48+
packer.toByteArray()
49+
}
5650
return msgpackMapper.readValue(msgpackBytes, ObjectMessage::class.java)
5751
}
5852

@@ -66,7 +60,7 @@ internal class DefaultLiveObjectSerializer : LiveObjectSerializer {
6660

6761
override fun readMsgpackArray(unpacker: MessageUnpacker): Array<Any> {
6862
val objectMessagesCount = unpacker.unpackArrayHeader()
69-
return Array(objectMessagesCount) { unpacker.readObjectMessage() }
63+
return Array(objectMessagesCount) { unpacker.unpackValue().asMapValue().toObjectMessage() }
7064
}
7165

7266
override fun writeMsgpackArray(objects: Array<out Any>?, packer: MessagePacker) {
@@ -101,7 +95,7 @@ internal class ObjectDataJsonSerializer : JsonSerializer<ObjectData>, JsonDeseri
10195
when (val v = value.value) {
10296
is Boolean -> obj.addProperty("boolean", v)
10397
is String -> obj.addProperty("string", v)
104-
is Number -> obj.addProperty("number", v)
98+
is Number -> obj.addProperty("number", v.toDouble())
10599
is Binary -> obj.addProperty("bytes", Base64.getEncoder().encodeToString(v.data))
106100
// Spec: OD4c5
107101
is JsonObject, is JsonArray -> {
@@ -132,7 +126,7 @@ internal class ObjectDataJsonSerializer : JsonSerializer<ObjectData>, JsonDeseri
132126
)
133127
}
134128
obj.has("string") -> ObjectValue(obj.get("string").asString)
135-
obj.has("number") -> ObjectValue(obj.get("number").asNumber)
129+
obj.has("number") -> ObjectValue(obj.get("number").asDouble)
136130
obj.has("bytes") -> ObjectValue(Binary(Base64.getDecoder().decode(obj.get("bytes").asString)))
137131
else -> throw JsonParseException("ObjectData must have one of the fields: boolean, string, number, or bytes")
138132
}
@@ -179,10 +173,32 @@ internal class ObjectDataMsgpackDeserializer : com.fasterxml.jackson.databind.Js
179173
)
180174
}
181175
node.has("string") -> ObjectValue(node.get("string").asText())
182-
node.has("number") -> ObjectValue(node.get("number").numberValue())
176+
node.has("number") -> ObjectValue(node.get("number").doubleValue())
183177
node.has("bytes") -> ObjectValue(Binary(node.get("bytes").binaryValue()))
184178
else -> throw IllegalArgumentException("ObjectData must have one of the fields: boolean, string, number, or bytes")
185179
}
186180
return ObjectData(objectId, value)
187181
}
188182
}
183+
184+
internal class InitialValueJsonSerializer : JsonSerializer<Binary>, JsonDeserializer<Binary> {
185+
override fun serialize(src: Binary, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
186+
return JsonPrimitive(Base64.getEncoder().encodeToString(src.data))
187+
}
188+
189+
override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): Binary {
190+
return Binary(Base64.getDecoder().decode(json.asString))
191+
}
192+
}
193+
194+
internal class InitialValueMsgpackSerializer : com.fasterxml.jackson.databind.JsonSerializer<Binary>() {
195+
override fun serialize(value: Binary?, gen: JsonGenerator, serializers: SerializerProvider) {
196+
gen.writeBinary(value?.data)
197+
}
198+
}
199+
200+
internal class InitialValueMsgpackDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<Binary>() {
201+
override fun deserialize(p: com.fasterxml.jackson.core.JsonParser, ctxt: DeserializationContext): Binary {
202+
return Binary(p.binaryValue)
203+
}
204+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package io.ably.lib.objects.unit
2+
3+
import io.ably.lib.objects.gson
4+
import io.ably.lib.objects.msgpackMapper
5+
import io.ably.lib.objects.unit.fixtures.*
6+
import io.ably.lib.types.ProtocolMessage
7+
import io.ably.lib.types.ProtocolSerializer
8+
import io.ably.lib.util.Serialisation
9+
import kotlinx.coroutines.test.runTest
10+
import org.junit.Assert.assertEquals
11+
import org.junit.Test
12+
import kotlin.test.assertNotNull
13+
14+
class ObjectMessageSerializationTest {
15+
16+
private val objectMessages = arrayOf(
17+
dummyObjectMessageWithStringData(),
18+
dummyObjectMessageWithBinaryData(),
19+
dummyObjectMessageWithNumberData(),
20+
dummyObjectMessageWithBooleanData(),
21+
dummyObjectMessageWithJsonObjectData(),
22+
dummyObjectMessageWithJsonArrayData()
23+
)
24+
25+
@Test
26+
fun testObjectMessageMsgPackSerialization() = runTest {
27+
val protocolMessage = ProtocolMessage()
28+
protocolMessage.action = ProtocolMessage.Action.`object`
29+
protocolMessage.state = objectMessages
30+
31+
// Serialize the ProtocolMessage containing ObjectMessages to MsgPack format
32+
val serializedProtoMsg = ProtocolSerializer.writeMsgpack(protocolMessage)
33+
assertNotNull(serializedProtoMsg)
34+
35+
// Deserialize back to ProtocolMessage
36+
val deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedProtoMsg)
37+
assertNotNull(deserializedProtoMsg)
38+
39+
deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) ->
40+
assertEquals(expected, actual as? io.ably.lib.objects.ObjectMessage)
41+
}
42+
}
43+
44+
@Test
45+
fun testObjectMessageJsonSerialization() = runTest {
46+
val protocolMessage = ProtocolMessage()
47+
protocolMessage.action = ProtocolMessage.Action.`object`
48+
protocolMessage.state = objectMessages
49+
50+
// Serialize the ProtocolMessage containing ObjectMessages to MsgPack format
51+
val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8)
52+
assertNotNull(serializedProtoMsg)
53+
54+
// Deserialize back to ProtocolMessage
55+
val deserializedProtoMsg = ProtocolSerializer.fromJSON(serializedProtoMsg)
56+
assertNotNull(deserializedProtoMsg)
57+
58+
deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) ->
59+
assertEquals(expected, (actual as? io.ably.lib.objects.ObjectMessage))
60+
}
61+
}
62+
63+
@Test
64+
fun testOmitNullInSerialization() = runTest {
65+
val nullableObject = object {
66+
val name = "Test Object"
67+
val description: String? = null // This will be omitted if using Gson with excludeNulls
68+
val value = 42
69+
}
70+
val serializedJsonString = gson.toJson(nullableObject)
71+
// check serializedObject does not contain the null field
72+
assertEquals("""{"name":"Test Object","value":42}""", serializedJsonString)
73+
74+
val serializedMsgpackBytes = msgpackMapper.writeValueAsBytes(nullableObject)
75+
// check serializedObject does not contain the null field
76+
assertEquals("""{"name":"Test Object","value":42}""", Serialisation.msgpackToGson(serializedMsgpackBytes).toString())
77+
}
78+
}

live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import io.ably.lib.objects.ensureMessageSizeWithinLimit
1111
import io.ably.lib.objects.size
1212
import io.ably.lib.transport.Defaults
1313
import io.ably.lib.types.AblyException
14+
import io.ktor.utils.io.core.*
1415
import io.mockk.every
1516
import io.mockk.mockk
1617
import kotlinx.coroutines.test.runTest
1718
import org.junit.Test
1819
import kotlin.test.assertEquals
1920
import kotlin.test.assertFailsWith
21+
import kotlin.text.toByteArray
2022

2123
class ObjectMessageSizeTest {
2224

@@ -79,7 +81,7 @@ class ObjectMessageSizeTest {
7981
), // Total ObjectCounter size: 8 bytes
8082

8183
nonce = "nonce123", // Not counted in operation size
82-
initialValue = "some-value", // Not counted in operation size
84+
initialValue = Binary("some-value".toByteArray()), // Not counted in operation size
8385
), // Total ObjectOperation size: 12 + 8 + 26 + 8 = 54 bytes
8486

8587
objectState = ObjectState(

0 commit comments

Comments
 (0)