From 35c8f075446c4668aa626167334d5527a03e0082 Mon Sep 17 00:00:00 2001 From: Janis Date: Fri, 5 Jun 2026 11:05:32 +0200 Subject: [PATCH] feat(api): define shared EventEnvelope and EventType for Redis EventBus - Add EventEnvelope case class (eventId, type, payload, timestamp, correlationId) - Add EventType enum with all known event types - Update account EventPublisher to use EventEnvelope instead of raw string interpolation - Add EventEnvelope/EventType to account NativeReflectionConfig - Add Jackson Scala and JSR310 modules to api dependencies - Add api module dependency to account module - Add NativeReflectionConfig rule to CLAUDE.md Closes NCS-90 https://knockoutwhist.youtrack.cloud/issue/NCS-90 Co-Authored-By: Claude Sonnet 4.6 --- modules/account/build.gradle.kts | 1 + .../config/NativeReflectionConfig.scala | 3 ++ .../account/service/EventPublisher.scala | 34 +++++++++---- modules/api/build.gradle.kts | 2 + .../de/nowchess/api/event/EventEnvelope.scala | 27 ++++++++++ .../de/nowchess/api/event/EventType.scala | 4 ++ .../api/event/EventEnvelopeTest.scala | 50 +++++++++++++++++++ 7 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 modules/api/src/main/scala/de/nowchess/api/event/EventEnvelope.scala create mode 100644 modules/api/src/main/scala/de/nowchess/api/event/EventType.scala create mode 100644 modules/api/src/test/scala/de/nowchess/api/event/EventEnvelopeTest.scala diff --git a/modules/account/build.gradle.kts b/modules/account/build.gradle.kts index 4342be4..3ffa0a2 100644 --- a/modules/account/build.gradle.kts +++ b/modules/account/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { } } + implementation(project(":modules:api")) implementation(project(":modules:security")) implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala index a32c18d..fafdbfa 100644 --- a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala +++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala @@ -12,10 +12,13 @@ import de.nowchess.account.domain.{ UserAccount, } import de.nowchess.account.dto.* +import de.nowchess.api.event.{EventEnvelope, EventType} import io.quarkus.runtime.annotations.RegisterForReflection @RegisterForReflection( targets = Array( + classOf[EventEnvelope], + classOf[EventType], classOf[UserAccount], classOf[BotAccount], classOf[OfficialBotAccount], diff --git a/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala b/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala index fc508a7..5e40251 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala @@ -1,6 +1,8 @@ package de.nowchess.account.service +import com.fasterxml.jackson.databind.ObjectMapper import de.nowchess.account.config.RedisConfig +import de.nowchess.api.event.{EventEnvelope, EventType} import io.quarkus.redis.datasource.RedisDataSource import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject @@ -10,22 +12,32 @@ import scala.compiletime.uninitialized class EventPublisher: // scalafix:off DisableSyntax.var - @Inject var redis: RedisDataSource = uninitialized - @Inject var redisConfig: RedisConfig = uninitialized + @Inject var redis: RedisDataSource = uninitialized + @Inject var redisConfig: RedisConfig = uninitialized + @Inject var objectMapper: ObjectMapper = uninitialized // scalafix:on DisableSyntax.var def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit = - val event = - s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}""" - redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event) - () + val payload = objectMapper.createObjectNode() + payload.put("gameId", gameId) + payload.put("playingAs", playingAs) + payload.put("difficulty", difficulty) + payload.put("botAccountId", botAccountId) + publish(s"${redisConfig.prefix}:bot:$botId:events", EventType.GameStart, payload) def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit = - val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}""" - redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$destUserId:events", event) - () + val payload = objectMapper.createObjectNode() + payload.put("challengeId", challengeId) + payload.put("challengerName", challengerName) + publish(s"${redisConfig.prefix}:user:$destUserId:events", EventType.ChallengeCreated, payload) def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit = - val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}""" - redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$challengerId:events", event) + val payload = objectMapper.createObjectNode() + payload.put("challengeId", challengeId) + payload.put("gameId", gameId) + publish(s"${redisConfig.prefix}:user:$challengerId:events", EventType.ChallengeAccepted, payload) + + private def publish(channel: String, eventType: EventType, payload: com.fasterxml.jackson.databind.node.ObjectNode): Unit = + val envelope = EventEnvelope.of(eventType, payload) + redis.pubsub(classOf[String]).publish(channel, objectMapper.writeValueAsString(envelope)) () diff --git a/modules/api/build.gradle.kts b/modules/api/build.gradle.kts index 44dea28..dbd2ea1 100644 --- a/modules/api/build.gradle.kts +++ b/modules/api/build.gradle.kts @@ -50,6 +50,8 @@ dependencies { } } implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/modules/api/src/main/scala/de/nowchess/api/event/EventEnvelope.scala b/modules/api/src/main/scala/de/nowchess/api/event/EventEnvelope.scala new file mode 100644 index 0000000..944fdd7 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/event/EventEnvelope.scala @@ -0,0 +1,27 @@ +package de.nowchess.api.event + +import com.fasterxml.jackson.databind.JsonNode +import java.time.Instant +import java.util.UUID + +final case class EventEnvelope( + eventId: UUID, + `type`: EventType, + payload: JsonNode, + timestamp: Instant, + correlationId: Option[String], +) + +object EventEnvelope: + def of( + `type`: EventType, + payload: JsonNode, + correlationId: Option[String] = None, + ): EventEnvelope = + EventEnvelope( + eventId = UUID.randomUUID(), + `type` = `type`, + payload = payload, + timestamp = Instant.now(), + correlationId = correlationId, + ) diff --git a/modules/api/src/main/scala/de/nowchess/api/event/EventType.scala b/modules/api/src/main/scala/de/nowchess/api/event/EventType.scala new file mode 100644 index 0000000..f07f424 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/event/EventType.scala @@ -0,0 +1,4 @@ +package de.nowchess.api.event + +enum EventType: + case GameStart, GameCreationRequest, GameCreationResponse, BotGameStart, ChallengeCreated, ChallengeAccepted diff --git a/modules/api/src/test/scala/de/nowchess/api/event/EventEnvelopeTest.scala b/modules/api/src/test/scala/de/nowchess/api/event/EventEnvelopeTest.scala new file mode 100644 index 0000000..66afa31 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/event/EventEnvelopeTest.scala @@ -0,0 +1,50 @@ +package de.nowchess.api.event + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class EventEnvelopeTest extends AnyFunSuite with Matchers: + + private val mapper = + val m = new ObjectMapper() + m.registerModule(DefaultScalaModule) + m.findAndRegisterModules() + m + + test("EventEnvelope round-trips through JSON") { + val payload = mapper.createObjectNode() + payload.put("gameId", "game-123") + payload.put("difficulty", 3) + + val original = EventEnvelope.of(EventType.GameStart, payload, Some("corr-abc")) + + val json = mapper.writeValueAsString(original) + val decoded = mapper.readValue(json, classOf[EventEnvelope]) + + decoded.eventId shouldBe original.eventId + decoded.`type` shouldBe original.`type` + decoded.payload shouldBe original.payload + decoded.timestamp shouldBe original.timestamp + decoded.correlationId shouldBe Some("corr-abc") + } + + test("EventEnvelope serializes without correlationId") { + val payload = mapper.createObjectNode() + payload.put("challengeId", "ch-1") + + val envelope = EventEnvelope.of(EventType.ChallengeCreated, payload) + val json = mapper.writeValueAsString(envelope) + val decoded = mapper.readValue(json, classOf[EventEnvelope]) + + decoded.`type` shouldBe EventType.ChallengeCreated + decoded.correlationId shouldBe None + } + + test("EventEnvelope.of generates unique eventIds") { + val payload = mapper.createObjectNode() + val e1 = EventEnvelope.of(EventType.BotGameStart, payload) + val e2 = EventEnvelope.of(EventType.BotGameStart, payload) + e1.eventId should not equal e2.eventId + }