feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
@@ -0,0 +1,39 @@
quarkus:
http:
port: 8084
application:
name: nowchess-ws
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
"%dev":
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
mp:
jwt:
verify:
publickey:
location: keys/public.pem
issuer: nowchess
"%deployed":
nowchess:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
mp:
jwt:
verify:
publickey:
location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
issuer: nowchess
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
WQIDAQAB
-----END PUBLIC KEY-----
@@ -0,0 +1,17 @@
package de.nowchess.ws.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(new DefaultScalaModule() {
override def version(): Version =
// scalafix:off DisableSyntax.null
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
@@ -0,0 +1,8 @@
package de.nowchess.ws.config
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(),
)
class NativeReflectionConfig
@@ -0,0 +1,12 @@
package de.nowchess.ws.config
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -0,0 +1,9 @@
package de.nowchess.ws.resource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
final case class ConnectionMeta(
gameId: String,
subscriber: PubSubCommands.RedisSubscriber,
playerId: Option[String],
)
@@ -0,0 +1,69 @@
package de.nowchess.ws.resource
import de.nowchess.ws.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import io.quarkus.websockets.next.*
import io.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@WebSocket(path = "/api/board/game/{gameId}/ws")
class GameWebSocketResource:
// scalafix:off DisableSyntax.var
@Inject
var redis: RedisDataSource = uninitialized
@Inject
var redisConfig: RedisConfig = uninitialized
@Inject
var jwtParser: JWTParser = uninitialized
// scalafix:on DisableSyntax.var
private val connections = new ConcurrentHashMap[String, ConnectionMeta]()
private def s2cTopic(gameId: String): String =
s"${redisConfig.prefix}:game:$gameId:s2c"
private def c2sTopic(gameId: String): String =
s"${redisConfig.prefix}:game:$gameId:c2s"
@OnOpen
def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
val gameId = connection.pathParam("gameId")
val playerId = Option(handshake.header("Authorization"))
.filter(_.nonEmpty)
.flatMap(token => Try(jwtParser.parse(token)).toOption)
.map(_.getSubject)
val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
val subscriber = redis.pubsub(classOf[String]).subscribe(s2cTopic(gameId), handler)
connections.put(connection.id(), ConnectionMeta(gameId, subscriber, playerId))
val connectedMsg = playerId match
case Some(pid) => s"""{"type":"CONNECTED","gameId":"$gameId","playerId":"$pid"}"""
case None => s"""{"type":"CONNECTED","gameId":"$gameId"}"""
redis.pubsub(classOf[String]).publish(c2sTopic(gameId), connectedMsg)
@OnTextMessage
def onTextMessage(connection: WebSocketConnection, message: String): Unit =
Option(connections.get(connection.id())).foreach { meta =>
val enriched = meta.playerId match
case Some(pid) => injectPlayerId(message, pid)
case None => message
redis.pubsub(classOf[String]).publish(c2sTopic(meta.gameId), enriched)
}
@OnClose
def onClose(connection: WebSocketConnection): Unit =
Option(connections.remove(connection.id())).foreach { meta =>
meta.subscriber.unsubscribe(s2cTopic(meta.gameId))
}
private def injectPlayerId(msg: String, pid: String): String =
val trimmed = msg.trim
if trimmed.endsWith("}") then trimmed.dropRight(1) + s""","playerId":"$pid"}"""
else msg
@@ -0,0 +1,53 @@
package de.nowchess.ws.resource
import de.nowchess.ws.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import io.quarkus.websockets.next.*
import io.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@WebSocket(path = "/api/user/ws")
class UserWebSocketResource:
// scalafix:off DisableSyntax.var
@Inject
var redis: RedisDataSource = uninitialized
@Inject
var redisConfig: RedisConfig = uninitialized
@Inject
var jwtParser: JWTParser = uninitialized
// scalafix:on DisableSyntax.var
private val connections = new ConcurrentHashMap[String, (String, PubSubCommands.RedisSubscriber)]()
private def userTopic(userId: String): String =
s"${redisConfig.prefix}:user:$userId:events"
@OnOpen
def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
val userIdOpt = Option(handshake.header("Authorization"))
.filter(_.nonEmpty)
.flatMap(token => Try(jwtParser.parse(token)).toOption)
.map(_.getSubject)
userIdOpt match
case None => connection.close().subscribe().`with`(_ => (), _ => ())
case Some(userId) =>
val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
val subscriber = redis.pubsub(classOf[String]).subscribe(userTopic(userId), handler)
connections.put(connection.id(), (userId, subscriber))
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
@OnClose
def onClose(connection: WebSocketConnection): Unit =
Option(connections.remove(connection.id())).foreach { (userId, subscriber) =>
subscriber.unsubscribe(userTopic(userId))
}
@@ -0,0 +1,11 @@
quarkus:
http:
port: 8084
application:
name: nowchess-ws
nowchess:
redis:
host: localhost
port: 6379
prefix: test