Skip to content

events kotlin serialization#1536

Merged
meladRaouf merged 7 commits into
mainfrom
ks-events
Jan 20, 2026
Merged

events kotlin serialization#1536
meladRaouf merged 7 commits into
mainfrom
ks-events

Conversation

@meladRaouf
Copy link
Copy Markdown
Collaborator

@meladRaouf meladRaouf commented Jan 15, 2026

JIRA ticket
Will be released in: 2026.1.0

Notable changes

  • Replaced Jackson annotations with Kotlin serialization annotations

Testing guidance

  • Create events and ensure they synchronize correctly with the backend.

Additional work checklist

  • Effect on other features and security has been considered
  • Design document marked as "In development" (if applicable)
  • External (Gitbook) and internal (Confluence) Documentation is up to date (or ticket created)
  • Test cases in Testiny are up to date (or ticket created)
  • Other teams notified about the changes (if applicable)

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the events module from Jackson serialization to Kotlin serialization. The migration involves adding @Serializable annotations to all event classes, removing Jackson-specific annotations and code, and implementing custom serializers for polymorphic types using Kotlin serialization's JsonContentPolymorphicSerializer.

Changes:

  • Replaced Jackson annotations with Kotlin serialization annotations (@Serializable, @SerialName)
  • Implemented custom serializers for polymorphic event payloads (Vero2InfoSnapshot, OneToOneMatch, OneToManyMatch)
  • Updated JsonHelper usage throughout the codebase to use Kotlin serialization's json.encodeToString and json.decodeFromString

Reviewed changes

Copilot reviewed 86 out of 86 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
Event.kt Removed Jackson annotations, added @Serializable, implemented toJson() helper method
EventPayload.kt Removed Jackson polymorphic annotations, added @Serializable
EventType.kt Added @Serializable, fixed constant naming (SAMPLE_UP_SYNC_REQUEST_KEY)
Various Event models Added @Serializable and @SerialName annotations with appropriate serialization keys
Vero2InfoSnapshotEvent.kt Implemented custom JsonContentPolymorphicSerializer for polymorphic payload types
OneToOneMatchEvent.kt Implemented custom serializer, restructured payload hierarchy
OneToManyMatchEvent.kt Changed from data class to class, implemented custom serializer
DbEvent.kt Updated to use Kotlin serialization, removed Jackson serialization module
DbEventScope.kt Updated serialization calls to use json.encodeToString/decodeFromString
EventMigration10to11.kt Updated to use Kotlin serialization, added @Serializable to snapshot classes
CoSyncEnrolmentRecordCreationEventDeserializer.kt Removed entire file (Jackson deserializer no longer needed)
CommCareCandidateRecordDataSource.kt Removed Jackson serialization module, updated to use Kotlin serialization
SimApiClientImpl.kt Updated error parsing to use Kotlin serialization
JsonHelper.kt Removed unused Jackson method overload
Modality.kt Removed @Serializable annotation (potential issue)
Test files Updated tests to use Kotlin serialization APIs

@meladRaouf meladRaouf requested review from a team, BurningAXE, TristramN, alex-vt, alexandr-simprints, Copilot, luhmirin-s and ybourgery and removed request for a team January 19, 2026 11:36
@meladRaouf meladRaouf marked this pull request as ready for review January 19, 2026 11:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 85 out of 85 changed files in this pull request and generated 10 comments.

Comment on lines +9 to 10
@Serializable
sealed class Event {
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of Jackson's @JsonTypeInfo and @JsonSubTypes without implementing a proper Kotlin Serialization polymorphic serializer means Event deserialization will fail. The current approach with manual serializerFor() only works for serialization. You need to implement a custom JsonContentPolymorphicSerializer or use @polymorphic with SerializersModule to handle deserialization of the Event sealed class hierarchy.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to 8
@Serializable
sealed class EventPayload {
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EventPayload sealed class is marked as @serializable but lacks a custom polymorphic serializer implementation. Similar to the Event class, this will cause deserialization failures. The previous Jackson @JsonTypeInfo/@JsonSubTypes annotations handled polymorphism using the "type" field, which needs to be replicated with a Kotlin Serialization custom serializer.

Copilot uses AI. Check for mistakes.
Comment on lines 71 to +78
data class Vero2InfoSnapshotPayloadForOldApi(
override val createdAt: Timestamp,
override val eventVersion: Int,
override val battery: BatteryInfo,
override val version: Vero2Version.Vero2OldApiVersion,
) : Vero2InfoSnapshotPayload(
createdAt,
eventVersion,
battery,
version,
)
override val eventVersion: Int = OLD_EVENT_VERSION,
override val type: EventType = VERO_2_INFO_SNAPSHOT,
override val endedAt: Timestamp? = null,
) : Vero2InfoSnapshotPayload()
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor parameters have been removed and replaced with default values in properties, changing the signature from Vero2InfoSnapshotPayloadForOldApi(createdAt, eventVersion, battery, version) to Vero2InfoSnapshotPayloadForOldApi(createdAt, battery, version). This is a breaking change that will cause compilation errors at call sites expecting the old constructor signature.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@meladRaouf Won't this actually be an issue if deserializing saved events from an old version?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BurningAXE Nothing changed in the event structure, and we already have tests that cover Vero2InfoSnapshotPayloadForOldApi

Comment on lines 86 to +89
data class OneToManyMatchPayloadV3(
val probeBiometricReferenceId: String,
val batches: List<OneToManyBatch>? = null,
override val createdAt: Timestamp,
override val eventVersion: Int,
override val endedAt: Timestamp?,
override val endedAt: Timestamp? = null,
override val pool: MatchPool,
override val matcher: String,
override val result: List<MatchEntry>?,
val probeBiometricReferenceId: String,
val batches: List<OneToManyBatch>? = null,
) : OneToManyMatchPayload(createdAt, eventVersion, endedAt, pool, matcher, result)
override val result: List<MatchEntry>? = null,
override val eventVersion: Int = EVENT_VERSION,
override val type: EventType = ONE_TO_MANY_MATCH,
) : OneToManyMatchPayload()
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field order in OneToManyMatchPayloadV3 has been changed - probeBiometricReferenceId and batches are now declared before other inherited properties. This changes the JSON serialization order and could break systems expecting a specific field order, though most JSON parsers handle this correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to 79
@Serializable(with = Vero2InfoSnapshotPayloadSerializer::class)
sealed class Vero2InfoSnapshotPayload : EventPayload() {
abstract override val type: EventType
abstract override val endedAt: Timestamp?

abstract val version: Vero2Version
abstract val battery: BatteryInfo

@Keep
@Serializable
data class Vero2InfoSnapshotPayloadForNewApi(
override val createdAt: Timestamp,
override val eventVersion: Int,
override val battery: BatteryInfo,
override val version: Vero2Version.Vero2NewApiVersion,
) : Vero2InfoSnapshotPayload(
createdAt,
eventVersion,
battery,
version,
)
override val eventVersion: Int = NEW_EVENT_VERSION,
override val type: EventType = VERO_2_INFO_SNAPSHOT,
override val endedAt: Timestamp? = null,
) : Vero2InfoSnapshotPayload()

@Deprecated(message = "Only used for backwards compatibility")
@Keep
@Serializable
data class Vero2InfoSnapshotPayloadForOldApi(
override val createdAt: Timestamp,
override val eventVersion: Int,
override val battery: BatteryInfo,
override val version: Vero2Version.Vero2OldApiVersion,
) : Vero2InfoSnapshotPayload(
createdAt,
eventVersion,
battery,
version,
)
override val eventVersion: Int = OLD_EVENT_VERSION,
override val type: EventType = VERO_2_INFO_SNAPSHOT,
override val endedAt: Timestamp? = null,
) : Vero2InfoSnapshotPayload()
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed toSafeString() method from the sealed class parent means that instances of Vero2InfoSnapshotPayloadForNewApi and Vero2InfoSnapshotPayloadForOldApi no longer have this method available, which is likely used for logging. This should be re-implemented in each subclass or as an abstract method in the parent.

Copilot uses AI. Check for mistakes.
Comment on lines 16 to 84
fun `deserialize handles old format with plain strings`() {
val json = JSON_TEMPLATE.format(PLAIN_MODULE, PLAIN_ATTENDANT)
val parser = objectMapper.createParser(json)
val context = mockk<DeserializationContext>()
every {
context.readTreeAsValue<List<BiometricReference>>(
any<JsonNode>(),
any<JavaType>(),
)
} returns emptyList()

val result = deserializer.deserialize(parser, context)
// Arrange
val jsonString = JSON_TEMPLATE.format(PLAIN_MODULE, PLAIN_ATTENDANT)

// Act
// We explicitly use the custom serializer we created in the previous step
val result = json.decodeFromString<EnrolmentRecordCreationEvent>(jsonString)

// Assert
assertEquals(EVENT_ID, result.id)
assertEquals(SUBJECT_ID, result.payload.subjectId)
assertEquals(PROJECT_ID, result.payload.projectId)

// Expect Raw strings because the JSON input was simple strings
assertEquals(TokenizableString.Raw(MODULE_ID), result.payload.moduleId)
assertEquals(TokenizableString.Raw(ATTENDANT_ID), result.payload.attendantId)
assertEquals(emptyList<BiometricReference>(), result.payload.biometricReferences)
}

@Test
fun `deserialize handles new format with TokenizableString`() {
val json = JSON_TEMPLATE.format(TOKENIZED_MODULE, RAW_ATTENDANT)
val parser = objectMapper.createParser(json)
val context = mockk<DeserializationContext>()
every {
context.readTreeAsValue(any(), TokenizableString::class.java)
} returns TokenizableString.Tokenized(ENCRYPTED_MODULE) andThen TokenizableString.Raw(UNENCRYPTED_ATTENDANT)
every {
context.readTreeAsValue<List<BiometricReference>>(
any<JsonNode>(),
any<JavaType>(),
)
} returns emptyList()

val result = deserializer.deserialize(parser, context)
// Arrange
// This input mimics the polymorphic object structure
val jsonString = JSON_TEMPLATE.format(TOKENIZED_MODULE, RAW_ATTENDANT)

// Act
val result = json.decodeFromString<EnrolmentRecordCreationEvent>(jsonString)

// Assert
assertEquals(EVENT_ID, result.id)
assertEquals(SUBJECT_ID, result.payload.subjectId)
assertEquals(PROJECT_ID, result.payload.projectId)

// These assertions assume that TokenizableString deserialization logic
// (inside the try/catch of the custom serializer) correctly parses these objects.
// If the parsing fails (e.g. discriminator mismatch), the serializer falls back to Raw(jsonString).
// ideally, this returns the typed objects:
assertEquals(TokenizableString.Tokenized(ENCRYPTED_MODULE), result.payload.moduleId)
assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId)
assertEquals(emptyList<BiometricReference>(), result.payload.biometricReferences)
}

@Test
fun `deserialize handles new format with TokenizableString but without explicit class`() {
val json = JSON_TEMPLATE.format(TOKENIZED_MODULE_NO_CLASS, RAW_ATTENDANT_NO_CLASS)
val parser = objectMapper.createParser(json)
val context = mockk<DeserializationContext>()
every {
context.readTreeAsValue(any(), TokenizableString::class.java)
} returns TokenizableString.Raw(ENCRYPTED_MODULE) andThen TokenizableString.Raw(UNENCRYPTED_ATTENDANT)
every {
context.readTreeAsValue<List<BiometricReference>>(
any<JsonNode>(),
any<JavaType>(),
)
} returns emptyList()

val result = deserializer.deserialize(parser, context)
// Arrange
val jsonString = JSON_TEMPLATE.format(TOKENIZED_MODULE_NO_CLASS, RAW_ATTENDANT_NO_CLASS)

// Act
val result = json.decodeFromString<EnrolmentRecordCreationEvent>(jsonString)

// Assert
assertEquals(EVENT_ID, result.id)
assertEquals(SUBJECT_ID, result.payload.subjectId)
assertEquals(PROJECT_ID, result.payload.projectId)
assertEquals(TokenizableString.Raw(ENCRYPTED_MODULE), result.payload.moduleId)
assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId)

// In the previous Serializer implementation, if the JSON is an object but fails
// standard deserialization (e.g. missing class discriminator),
// the catch block returns TokenizableString.Raw(element.toString()).
// Therefore, we verify that the fallback logic worked.
// Note: The original test mocked this to return just the value "encrypted-module-1".
// The real implementation likely returns the full JSON object string unless
// TokenizableString has a custom serializer that handles missing discriminators.

// Assuming the fallback logic wraps the JSON string:
assert(result.payload.moduleId is TokenizableString.Raw)
assert(result.payload.attendantId is TokenizableString.Raw)

assertEquals(emptyList<BiometricReference>(), result.payload.biometricReferences)
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire CoSyncEnrolmentRecordCreationEventDeserializer class has been deleted, but the migration test file still contains test cases that reference this deserializer's behavior for handling plain strings vs TokenizableString objects. The tests need to be updated to reflect how the new Kotlin Serialization handles this backwards compatibility, or the custom deserialization logic needs to be re-implemented.

Copilot uses AI. Check for mistakes.
Comment on lines 69 to 87
master = 10129,
)
val batteryArg = Vero2InfoSnapshotEvent.BatteryInfo(0, 1, 2, 3)
val payload = Vero2InfoSnapshotEvent.Vero2InfoSnapshotPayload.Vero2InfoSnapshotPayloadForOldApi(
CREATED_AT,
Vero2InfoSnapshotEvent.OLD_EVENT_VERSION,
batteryArg,
versionArg,
)
val payload: Vero2InfoSnapshotEvent.Vero2InfoSnapshotPayload = Vero2InfoSnapshotEvent.Vero2InfoSnapshotPayload
.Vero2InfoSnapshotPayloadForOldApi(
CREATED_AT,
batteryArg,
versionArg,
)
val expectedEvent = Vero2InfoSnapshotEvent(
id = "3afb1b9e-b263-4073-b773-6e1dac20d72f",
scopeId = "6dcb3810-4789-4149-8fea-473ffb520958",
payload = payload,
type = VERO_2_INFO_SNAPSHOT,
)

val eventAsString = Vero2InfoSnapshotEventSample.oldApiJsonEventString
val actualEvent = JsonHelper.fromJson(eventAsString, object : TypeReference<Event>() {})

val actualEvent = JsonHelper.json.decodeFromString<Event>(eventAsString)
assertThat(expectedEvent).isEqualTo(actualEvent)
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The eventVersion parameter was removed from constructor calls but the tests don't verify that the default value is correctly applied. Given that eventVersion is critical for polymorphic deserialization in Vero2InfoSnapshotPayloadSerializer, there should be assertions to verify the eventVersion is set correctly to OLD_EVENT_VERSION for the old API payload.

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Jan 19, 2026

@meladRaouf meladRaouf merged commit 2944798 into main Jan 20, 2026
14 checks passed
@meladRaouf meladRaouf deleted the ks-events branch January 20, 2026 13:45
override val type: EventType = ONE_TO_MANY_MATCH,
) : EventPayload() {
override fun toSafeString(): String = "matcher: $matcher, pool: ${pool.type}, size: ${pool.count}, results: ${result?.size}"
// FIX 2: Apply Custom Serializer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we don't need this comment anymore :)

open val fingerComparisonStrategy: FingerComparisonStrategy?,
override val type: EventType = ONE_TO_ONE_MATCH,
) : EventPayload() {
@Serializable(with = OneToOneMatchPayloadSerializer::class) // Now this works!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for letting us know, Copilot! Now please delete your comment :D

) : EventPayload() {
@Serializable(with = OneToOneMatchPayloadSerializer::class) // Now this works!
sealed class OneToOneMatchPayload : EventPayload() {
abstract override val type: EventType // This caused the conflict, now it's just a property
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one, too :D

private val deserializer = CoSyncEnrolmentRecordCreationEventDeserializer()
private val objectMapper = ObjectMapper()
// Configure Json to be lenient if necessary, though strict is better for validation.
// 'ignoreUnknownKeys' helps if the JSON contains fields not in the model.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we don't need these comments :)


// Assuming the fallback logic wraps the JSON string:
assert(result.payload.moduleId is TokenizableString.Raw)
assert(result.payload.attendantId is TokenizableString.Raw)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also verify the values, not just the types

const val ENCRYPTED_MODULE = "encrypted-module-1"
const val UNENCRYPTED_ATTENDANT = "unencrypted-attendant-1"

// The template remains the same, assuming it represents the actual contract
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this WIP comment


// Note: Verify if "className" is the correct discriminator for your KSerializer config.
// Standard KSerialization uses "type". If your data uses "className",
// TokenizableString must be annotated with @JsonClassDiscriminator("className")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be verified and the comment deleted

Copy link
Copy Markdown
Contributor

@BurningAXE BurningAXE left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate this has already been merged but left a few comments that I think need addressing in a follow-up PR

@meladRaouf
Copy link
Copy Markdown
Collaborator Author

Appreciate this has already been merged but left a few comments that I think need addressing in a follow-up PR

@BurningAXE Many thanks for your reviews, I am going to address them in a new PR now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants