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
+117
View File
@@ -0,0 +1,117 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("io.quarkus")
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
dependencies {
runtimeOnly("io.quarkus:quarkus-jdbc-h2")
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation("org.scala-lang:scala3-library_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-arc")
implementation("io.quarkus:quarkus-hibernate-orm-panache")
implementation("io.quarkus:quarkus-jdbc-postgresql")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-jwt-build")
implementation("io.quarkus:quarkus-elytron-security-common")
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("io.quarkus:quarkus-redis-client")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testImplementation("io.quarkus:quarkus-junit")
testImplementation("io.rest-assured:rest-assured")
testImplementation("io.quarkus:quarkus-jdbc-h2")
testImplementation("io.quarkus:quarkus-test-security")
testImplementation("io.quarkus:quarkus-junit5-mockito")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
}
configurations.scoverage {
resolutionStrategy.eachDependency {
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
}
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest", "junit-jupiter")
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
@@ -0,0 +1,27 @@
{
"reflection": [
{ "type": "scala.Tuple1[]" },
{ "type": "scala.Tuple2[]" },
{ "type": "scala.Tuple3[]" },
{ "type": "scala.Tuple4[]" },
{ "type": "scala.Tuple5[]" },
{ "type": "scala.Tuple6[]" },
{ "type": "scala.Tuple7[]" },
{ "type": "scala.Tuple8[]" },
{ "type": "scala.Tuple9[]" },
{ "type": "scala.Tuple10[]" },
{ "type": "scala.Tuple11[]" },
{ "type": "scala.Tuple12[]" },
{ "type": "scala.Tuple13[]" },
{ "type": "scala.Tuple14[]" },
{ "type": "scala.Tuple15[]" },
{ "type": "scala.Tuple16[]" },
{ "type": "scala.Tuple17[]" },
{ "type": "scala.Tuple18[]" },
{ "type": "scala.Tuple19[]" },
{ "type": "scala.Tuple20[]" },
{ "type": "scala.Tuple21[]" },
{ "type": "scala.Tuple22[]" },
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
]
}
@@ -0,0 +1,64 @@
quarkus:
http:
port: 8083
application:
name: nowchess-account
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client:
core-service:
url: http://localhost:8080
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
swagger-ui:
always-include: true
path: /swagger-ui
datasource:
db-kind: h2
username: sa
password: ""
jdbc:
url: jdbc:h2:mem:nowchess;DB_CLOSE_DELAY=-1
hibernate-orm:
schema-management:
strategy: drop-and-create
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
internal:
secret: 123abc
"%deployed":
quarkus:
rest-client:
core-service:
url: ${CORE_SERVICE_URL}
nowchess:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
datasource:
db-kind: postgresql
username: ${DB_USER}
password: ${DB_PASSWORD}
jdbc:
url: ${DB_URL}
hibernate-orm:
schema-management:
strategy: update
mp:
jwt:
verify:
publickey:
location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
issuer: nowchess
smallrye:
jwt:
sign:
key:
location: ${JWT_PRIVATE_KEY_PATH:keys/private.pem}
@@ -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,27 @@
package de.nowchess.account.client
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CorePlayerInfo(id: String, displayName: String)
case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
case class CoreCreateGameRequest(
white: Option[CorePlayerInfo],
black: Option[CorePlayerInfo],
timeControl: Option[CoreTimeControl],
mode: Option[String],
)
case class CoreGameResponse(gameId: String)
@Path("/api/board/game")
@RegisterRestClient(configKey = "core-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait CoreGameClient:
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(req: CoreCreateGameRequest): CoreGameResponse
@@ -0,0 +1,17 @@
package de.nowchess.account.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,50 @@
package de.nowchess.account.config
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
import de.nowchess.account.domain.{
BotAccount,
Challenge,
ChallengeColor,
ChallengeStatus,
DeclineReason,
OfficialBotAccount,
TimeControl,
UserAccount,
}
import de.nowchess.account.dto.*
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[UserAccount],
classOf[BotAccount],
classOf[OfficialBotAccount],
classOf[Challenge],
classOf[ChallengeColor],
classOf[ChallengeStatus],
classOf[DeclineReason],
classOf[TimeControl],
classOf[LoginRequest],
classOf[TokenResponse],
classOf[PlayerInfo],
classOf[PublicAccountDto],
classOf[BotAccountDto],
classOf[BotAccountWithTokenDto],
classOf[OfficialBotAccountDto],
classOf[CreateBotAccountRequest],
classOf[UpdateBotNameRequest],
classOf[RotatedTokenDto],
classOf[TimeControlDto],
classOf[ChallengeRequest],
classOf[ChallengeDto],
classOf[DeclineRequest],
classOf[ChallengeListDto],
classOf[ErrorDto],
classOf[CorePlayerInfo],
classOf[CoreTimeControl],
classOf[CoreCreateGameRequest],
classOf[CoreGameResponse],
classOf[OfficialChallengeResponse],
),
)
class NativeReflectionConfig
@@ -0,0 +1,12 @@
package de.nowchess.account.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,56 @@
package de.nowchess.account.domain
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
import jakarta.persistence.*
import scala.compiletime.uninitialized
import java.time.Instant
import java.util.UUID
import scala.Conversion
@Entity
@Table(name = "challenges")
class Challenge extends PanacheEntityBase:
// scalafix:off DisableSyntax.var
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID = uninitialized
@ManyToOne
var challenger: UserAccount = uninitialized
@ManyToOne
var destUser: UserAccount = uninitialized
@Convert(converter = classOf[ChallengeColorConverter])
@Column(columnDefinition = "varchar(255)")
var color: ChallengeColor = uninitialized
@Convert(converter = classOf[ChallengeStatusConverter])
@Column(columnDefinition = "varchar(255)")
var status: ChallengeStatus = uninitialized
@Convert(converter = classOf[DeclineReasonConverter])
@Column(nullable = true, columnDefinition = "varchar(255)")
var declineReason: DeclineReason = uninitialized
var timeControlType: String = uninitialized
@Column(nullable = true)
var timeControlLimit: java.lang.Integer = uninitialized
@Column(nullable = true)
var timeControlIncrement: java.lang.Integer = uninitialized
var createdAt: Instant = uninitialized
var expiresAt: Instant = uninitialized
@Column(nullable = true)
var gameId: String = uninitialized
// scalafix:on
def gameIdOpt: Option[String] = Option(gameId)
def declineReasonOpt: Option[DeclineReason] = Option(declineReason)
def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue())
def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue())
@@ -0,0 +1,4 @@
package de.nowchess.account.domain
enum ChallengeColor:
case White, Black, Random
@@ -0,0 +1,12 @@
package de.nowchess.account.domain
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter(autoApply = true)
class ChallengeColorConverter extends AttributeConverter[ChallengeColor, String]:
override def convertToDatabaseColumn(attribute: ChallengeColor): String =
Option(attribute).map(_.toString).orNull
override def convertToEntityAttribute(dbData: String): ChallengeColor =
Option(dbData).map(ChallengeColor.valueOf).orNull
@@ -0,0 +1,4 @@
package de.nowchess.account.domain
enum ChallengeStatus:
case Created, Canceled, Declined, Accepted
@@ -0,0 +1,12 @@
package de.nowchess.account.domain
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter(autoApply = true)
class ChallengeStatusConverter extends AttributeConverter[ChallengeStatus, String]:
override def convertToDatabaseColumn(attribute: ChallengeStatus): String =
Option(attribute).map(_.toString).orNull
override def convertToEntityAttribute(dbData: String): ChallengeStatus =
Option(dbData).map(ChallengeStatus.valueOf).orNull
@@ -0,0 +1,4 @@
package de.nowchess.account.domain
enum DeclineReason:
case Generic, Later, TooFast, TooSlow, TimeControl, Rated, Casual, Standard, Variant, NoBot, OnlyBot
@@ -0,0 +1,12 @@
package de.nowchess.account.domain
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter(autoApply = true)
class DeclineReasonConverter extends AttributeConverter[DeclineReason, String]:
override def convertToDatabaseColumn(attribute: DeclineReason): String =
Option(attribute).map(_.toString).orNull
override def convertToEntityAttribute(dbData: String): DeclineReason =
Option(dbData).map(DeclineReason.valueOf).orNull
@@ -0,0 +1,7 @@
package de.nowchess.account.domain
sealed trait TimeControl
object TimeControl:
case class Clock(limit: Int, increment: Int) extends TimeControl
case object Unlimited extends TimeControl
@@ -0,0 +1,78 @@
package de.nowchess.account.domain
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
import jakarta.persistence.*
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "user_accounts")
class UserAccount extends PanacheEntityBase:
// scalafix:off DisableSyntax.var
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID = uninitialized
@Column(unique = true, nullable = false)
var username: String = uninitialized
@Column(unique = true, nullable = false)
var email: String = uninitialized
var passwordHash: String = uninitialized
var rating: Int = 1500
var createdAt: Instant = uninitialized
var banned: Boolean = false
@OneToMany(mappedBy = "owner", cascade = Array(CascadeType.ALL), orphanRemoval = true)
var botAccounts: java.util.List[BotAccount] = uninitialized
// scalafix:on
def getBotAccounts: List[BotAccount] = Option(botAccounts).map(_.asScala.toList).getOrElse(Nil)
@Entity
@Table(name = "bot_accounts")
class BotAccount extends PanacheEntityBase:
// scalafix:off DisableSyntax.var
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID = uninitialized
@Column(nullable = false)
var name: String = uninitialized
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
var owner: UserAccount = uninitialized
@Column(unique = true, nullable = false, length = 256)
var token: String = uninitialized
var rating: Int = 1500
var createdAt: Instant = uninitialized
var banned: Boolean = false
// scalafix:on
@Entity
@Table(name = "official_bot_accounts")
class OfficialBotAccount extends PanacheEntityBase:
// scalafix:off DisableSyntax.var
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID = uninitialized
@Column(nullable = false)
var name: String = uninitialized
var rating: Int = 1500
var createdAt: Instant = uninitialized
// scalafix:on
@@ -0,0 +1,49 @@
package de.nowchess.account.dto
case class RegisterRequest(username: String, email: String, password: String)
case class LoginRequest(username: String, password: String)
case class TokenResponse(token: String)
case class PlayerInfo(id: String, name: String, rating: Int)
case class PublicAccountDto(id: String, username: String, rating: Int, createdAt: String)
case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[Int])
case class ChallengeRequest(color: String, timeControl: TimeControlDto)
case class ChallengeDto(
id: String,
challenger: PlayerInfo,
destUser: PlayerInfo,
variant: String,
color: String,
timeControl: TimeControlDto,
status: String,
declineReason: Option[String],
gameId: Option[String],
createdAt: String,
expiresAt: String,
)
case class DeclineRequest(reason: Option[String])
case class ChallengeListDto(in: List[ChallengeDto], out: List[ChallengeDto])
case class ErrorDto(error: String)
case class CreateBotAccountRequest(name: String)
case class UpdateBotNameRequest(name: String)
case class BotAccountDto(id: String, name: String, rating: Int, createdAt: String)
case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String)
case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
@@ -0,0 +1,23 @@
package de.nowchess.account.error
enum AccountError:
case UsernameTaken(username: String)
case EmailAlreadyRegistered(email: String)
case InvalidCredentials
case UserNotFound
case BotNotFound
case BotLimitExceeded
case NotAuthorized
case UserBanned
case BotBanned
def message: String = this match
case UsernameTaken(u) => s"Username '$u' is already taken"
case EmailAlreadyRegistered(e) => s"Email '$e' is already registered"
case InvalidCredentials => "Invalid credentials"
case UserNotFound => "User not found"
case BotNotFound => "Bot account not found"
case BotLimitExceeded => "Maximum of 5 bot accounts per user exceeded"
case NotAuthorized => "Not authorized to perform this action"
case UserBanned => "User account is banned"
case BotBanned => "Bot account is banned"
@@ -0,0 +1,25 @@
package de.nowchess.account.error
enum ChallengeError:
case UserNotFound(username: String)
case ChallengerNotFound
case CannotChallengeSelf
case DuplicateChallenge
case InvalidColor(color: String)
case InvalidDeclineReason(reason: String)
case ChallengeNotFound
case ChallengeNotActive
case NotAuthorized
case GameCreationFailed
def message: String = this match
case UserNotFound(u) => s"User '$u' not found"
case ChallengerNotFound => "Challenger not found"
case CannotChallengeSelf => "Cannot challenge yourself"
case DuplicateChallenge => "Active challenge to this user already exists"
case InvalidColor(c) => s"Unknown color: $c"
case InvalidDeclineReason(r) => s"Unknown decline reason: $r"
case ChallengeNotFound => "Challenge not found"
case ChallengeNotActive => "Challenge is not active"
case NotAuthorized => "Not authorized"
case GameCreationFailed => "Failed to create game"
@@ -0,0 +1,43 @@
package de.nowchess.account.filter
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
import jakarta.ws.rs.core.Response
import jakarta.ws.rs.ext.Provider
import org.eclipse.microprofile.jwt.JsonWebToken
import scala.compiletime.uninitialized
@Provider
@ApplicationScoped
class AlreadyLoggedInFilter extends ContainerRequestFilter:
@Inject
// scalafix:off DisableSyntax.var
var jwt: JsonWebToken = uninitialized
// scalafix:on
override def filter(context: ContainerRequestContext): Unit =
val path = context.getUriInfo.getPath
val method = context.getMethod
if isProtectedEndpoint(path, method) && isAuthenticated then
context.abortWith(
Response
.status(Response.Status.BAD_REQUEST)
.entity("""{"error":"Already logged in"}""")
.build(),
)
private def isAuthenticated: Boolean =
// scalafix:off DisableSyntax.null
try jwt.getName != null
catch
case _ => false
// scalafix:on DisableSyntax.null
private def isProtectedEndpoint(path: String, method: String): Boolean =
(path.contains("/api/account") || path.contains("/account")) &&
((path.endsWith("/api/account") && method == "POST") ||
(path.endsWith("/account") && method == "POST") ||
(path.contains("/login") && method == "POST"))
@@ -0,0 +1,98 @@
package de.nowchess.account.repository
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.persistence.EntityManager
import java.util.UUID
import scala.jdk.CollectionConverters.*
@ApplicationScoped
class UserAccountRepository:
@Inject
// scalafix:off DisableSyntax.var
var em: EntityManager = scala.compiletime.uninitialized
// scalafix:on
def findByUsername(username: String): Option[UserAccount] =
em.createQuery("FROM UserAccount WHERE username = :username", classOf[UserAccount])
.setParameter("username", username)
.getResultList
.stream()
.findFirst()
.map(Option(_))
.orElse(None)
def findById(id: UUID): Option[UserAccount] =
Option(em.find(classOf[UserAccount], id))
def persist(account: UserAccount): UserAccount =
em.persist(account)
account
def findByEmail(email: String): Option[UserAccount] =
em.createQuery("FROM UserAccount WHERE email = :email", classOf[UserAccount])
.setParameter("email", email)
.getResultList
.asScala
.headOption
def findAll(): List[UserAccount] =
em.createQuery("FROM UserAccount", classOf[UserAccount]).getResultList.asScala.toList
@ApplicationScoped
class BotAccountRepository:
@Inject
// scalafix:off DisableSyntax.var
var em: EntityManager = scala.compiletime.uninitialized
// scalafix:on
def findById(id: UUID): Option[BotAccount] =
Option(em.find(classOf[BotAccount], id))
def findByOwner(ownerId: UUID): List[BotAccount] =
em.createQuery("FROM BotAccount WHERE owner.id = :ownerId", classOf[BotAccount])
.setParameter("ownerId", ownerId)
.getResultList
.asScala
.toList
def persist(bot: BotAccount): BotAccount =
em.persist(bot)
bot
def delete(botId: UUID): Unit =
em.find(classOf[BotAccount], botId) match
case bot: BotAccount => em.remove(bot)
def findByToken(token: String): Option[BotAccount] =
em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
.setParameter("token", token)
.getResultList
.asScala
.headOption
@ApplicationScoped
class OfficialBotAccountRepository:
@Inject
// scalafix:off DisableSyntax.var
var em: EntityManager = scala.compiletime.uninitialized
// scalafix:on
def findById(id: UUID): Option[OfficialBotAccount] =
Option(em.find(classOf[OfficialBotAccount], id))
def findAll(): List[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
def persist(bot: OfficialBotAccount): OfficialBotAccount =
em.persist(bot)
bot
def delete(botId: UUID): Unit =
em.find(classOf[OfficialBotAccount], botId) match
case bot: OfficialBotAccount => em.remove(bot)
@@ -0,0 +1,62 @@
package de.nowchess.account.repository
import de.nowchess.account.domain.{Challenge, ChallengeStatus}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.persistence.EntityManager
import java.time.Instant
import java.util.UUID
import scala.jdk.CollectionConverters.*
@ApplicationScoped
class ChallengeRepository:
@Inject
// scalafix:off DisableSyntax.var
var em: EntityManager = scala.compiletime.uninitialized
// scalafix:on
def findActiveByChallengerId(challengerId: UUID): List[Challenge] =
em.createQuery(
"FROM Challenge WHERE challenger.id = :cid AND status = :status AND expiresAt > :now",
classOf[Challenge],
).setParameter("cid", challengerId)
.setParameter("status", ChallengeStatus.Created)
.setParameter("now", Instant.now())
.getResultList
.asScala
.toList
def findActiveByDestUserId(destUserId: UUID): List[Challenge] =
em.createQuery(
"FROM Challenge WHERE destUser.id = :uid AND status = :status AND expiresAt > :now",
classOf[Challenge],
).setParameter("uid", destUserId)
.setParameter("status", ChallengeStatus.Created)
.setParameter("now", Instant.now())
.getResultList
.asScala
.toList
def findDuplicateChallenge(challengerId: UUID, destUserId: UUID): Option[Challenge] =
em.createQuery(
"FROM Challenge WHERE challenger.id = :cid AND destUser.id = :uid AND status = :status AND expiresAt > :now",
classOf[Challenge],
).setParameter("cid", challengerId)
.setParameter("uid", destUserId)
.setParameter("status", ChallengeStatus.Created)
.setParameter("now", Instant.now())
.getResultList
.asScala
.headOption
def findById(id: UUID): Option[Challenge] =
Option(em.find(classOf[Challenge], id))
def persist(challenge: Challenge): Challenge =
em.persist(challenge)
challenge
def merge(challenge: Challenge): Challenge =
em.merge(challenge)
@@ -0,0 +1,202 @@
package de.nowchess.account.resource
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
import scala.compiletime.uninitialized
import java.util.UUID
@Path("/api/account")
@ApplicationScoped
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class AccountResource:
// scalafix:off DisableSyntax.var
@Inject
var accountService: AccountService = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
// scalafix:on
@POST
def register(req: RegisterRequest): Response =
accountService.register(req) match
case Right(account) =>
Response.ok(toPublicDto(account)).build()
case Left(error) =>
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error.message)).build()
@POST
@Path("/login")
def login(req: LoginRequest): Response =
accountService.login(req) match
case Right(token) =>
Response.ok(TokenResponse(token)).build()
case Left(AccountError.UserBanned) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
case Left(error) =>
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
@GET
@Path("/me")
@RolesAllowed(Array("**"))
def me(): Response =
val id = UUID.fromString(jwt.getSubject)
accountService.findById(id) match
case Some(account) => Response.ok(toPublicDto(account)).build()
case None => Response.status(Response.Status.NOT_FOUND).build()
@GET
@Path("/{username}")
def publicProfile(@PathParam("username") username: String): Response =
accountService.findByUsername(username) match
case Some(account) => Response.ok(toPublicDto(account)).build()
case None => Response.status(Response.Status.NOT_FOUND).build()
@POST
@Path("/{userId}/ban")
@RolesAllowed(Array("Admin"))
def banUser(@PathParam("userId") userId: String): Response =
accountService.banUser(UUID.fromString(userId)) match
case Right(user) => Response.ok(toPublicDto(user)).build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
@POST
@Path("/{userId}/unban")
@RolesAllowed(Array("Admin"))
def unbanUser(@PathParam("userId") userId: String): Response =
accountService.unbanUser(UUID.fromString(userId)) match
case Right(user) => Response.ok(toPublicDto(user)).build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
@POST
@Path("/bots")
@RolesAllowed(Array("**"))
def createBotAccount(req: CreateBotAccountRequest): Response =
val ownerId = UUID.fromString(jwt.getSubject)
accountService.createBotAccount(ownerId, req.name) match
case Right(bot) =>
Response.status(Response.Status.CREATED).entity(toBotDtoWithToken(bot)).build()
case Left(error) =>
val status = error match
case AccountError.BotLimitExceeded => Response.Status.BAD_REQUEST
case _ => Response.Status.INTERNAL_SERVER_ERROR
Response.status(status).entity(ErrorDto(error.message)).build()
@GET
@Path("/bots")
@RolesAllowed(Array("**"))
def listBotAccounts(): Response =
val ownerId = UUID.fromString(jwt.getSubject)
val bots = accountService.getBotAccounts(ownerId)
Response.ok(bots.map(toBotDto)).build()
@PUT
@Path("/bots/{botId}")
@RolesAllowed(Array("**"))
def updateBotName(@PathParam("botId") botId: String, req: UpdateBotNameRequest): Response =
val ownerId = UUID.fromString(jwt.getSubject)
accountService.updateBotName(UUID.fromString(botId), ownerId, req.name) match
case Right(bot) => Response.ok(toBotDto(bot)).build()
case Left(AccountError.NotAuthorized) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
@POST
@Path("/bots/{botId}/rotate-token")
@RolesAllowed(Array("**"))
def rotateBotToken(@PathParam("botId") botId: String): Response =
val ownerId = UUID.fromString(jwt.getSubject)
accountService.rotateBotToken(UUID.fromString(botId), ownerId) match
case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build()
case Left(AccountError.NotAuthorized) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
@DELETE
@Path("/bots/{botId}")
@RolesAllowed(Array("**"))
def deleteBotAccount(@PathParam("botId") botId: String): Response =
val ownerId = UUID.fromString(jwt.getSubject)
val botUuid = UUID.fromString(botId)
accountService.getBotAccountWithOwnerCheck(botUuid, ownerId) match
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(AccountError.BotNotFound.message)).build()
case Some(None) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
case Some(Some(_)) =>
accountService.deleteBotAccount(botUuid) match
case Right(_) => Response.noContent().build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
private def toPublicDto(account: UserAccount): PublicAccountDto =
PublicAccountDto(
id = account.id.toString,
username = account.username,
rating = account.rating,
createdAt = account.createdAt.toString,
)
private def toBotDto(bot: BotAccount): BotAccountDto =
BotAccountDto(
id = bot.id.toString,
name = bot.name,
rating = bot.rating,
createdAt = bot.createdAt.toString,
)
private def toBotDtoWithToken(bot: BotAccount): BotAccountWithTokenDto =
BotAccountWithTokenDto(
id = bot.id.toString,
name = bot.name,
rating = bot.rating,
token = bot.token,
createdAt = bot.createdAt.toString,
)
@GET
@Path("/official-bots")
def getOfficialBots: Response =
val bots = accountService.getOfficialBotAccounts()
Response.ok(bots.map(toOfficialBotDto)).build()
@POST
@Path("/official-bots")
@RolesAllowed(Array("Admin"))
def createOfficialBot(req: CreateBotAccountRequest): Response =
accountService.createOfficialBotAccount(req.name) match
case Right(bot) =>
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
case Left(error) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
@DELETE
@Path("/official-bots/{botId}")
@RolesAllowed(Array("Admin"))
def deleteOfficialBot(@PathParam("botId") botId: String): Response =
accountService.deleteOfficialBotAccount(UUID.fromString(botId)) match
case Right(_) => Response.noContent().build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
private def toOfficialBotDto(bot: OfficialBotAccount): OfficialBotAccountDto =
OfficialBotAccountDto(
id = bot.id.toString,
name = bot.name,
rating = bot.rating,
createdAt = bot.createdAt.toString,
)
@@ -0,0 +1,88 @@
package de.nowchess.account.resource
import de.nowchess.account.dto.*
import de.nowchess.account.error.ChallengeError
import de.nowchess.account.service.ChallengeService
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
import scala.compiletime.uninitialized
import java.util.UUID
@Path("/api/challenge")
@ApplicationScoped
@RolesAllowed(Array("**"))
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class ChallengeResource:
// scalafix:off DisableSyntax.var
@Inject
var challengeService: ChallengeService = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
// scalafix:on
@POST
@Path("/{username}")
def create(@PathParam("username") username: String, req: ChallengeRequest): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.create(userId, username, req) match
case Right(challenge) =>
Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build()
case Left(error) =>
val status = error match
case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND
case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST
case _ => Response.Status.CONFLICT
Response.status(status).entity(ErrorDto(error.message)).build()
@GET
def list(): Response =
val userId = UUID.fromString(jwt.getSubject)
Response.ok(challengeService.listForUser(userId)).build()
@GET
@Path("/{id}")
def get(@PathParam("id") id: UUID): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.findById(id, userId) match
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
@POST
@Path("/{id}/accept")
def accept(@PathParam("id") id: UUID): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.accept(id, userId) match
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
@POST
@Path("/{id}/decline")
def decline(@PathParam("id") id: UUID, req: DeclineRequest): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.decline(id, userId, req) match
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
@POST
@Path("/{id}/cancel")
def cancel(@PathParam("id") id: UUID): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.cancel(id, userId) match
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
private def errorResponse(error: ChallengeError): Response =
val status = error match
case ChallengeError.ChallengeNotFound => Response.Status.NOT_FOUND
case ChallengeError.NotAuthorized => Response.Status.FORBIDDEN
case ChallengeError.GameCreationFailed => Response.Status.INTERNAL_SERVER_ERROR
case _ => Response.Status.BAD_REQUEST
Response.status(status).entity(ErrorDto(error.message)).build()
@@ -0,0 +1,91 @@
package de.nowchess.account.resource
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
import de.nowchess.account.service.{AccountService, EventPublisher}
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.util.UUID
import java.util.concurrent.ThreadLocalRandom
@Path("/api/challenge/official")
@ApplicationScoped
@RolesAllowed(Array("**"))
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class OfficialChallengeResource:
// scalafix:off DisableSyntax.var
@Inject var accountService: AccountService = uninitialized
@Inject var jwt: JsonWebToken = uninitialized
@Inject var botEventPublisher: EventPublisher = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
// scalafix:on
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
@POST
@Path("/{botName}")
def challengeWithDifficulty(
@PathParam("botName") botName: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("color") color: String,
): Response =
if difficulty < 1000 || difficulty > 2800 then
Response
.status(Response.Status.BAD_REQUEST)
.entity(ErrorDto("difficulty must be between 1000 and 2800"))
.build()
else
val normalizedColor = Option(color).map(_.toLowerCase).getOrElse("random")
normalizedColor match
case "white" | "black" | "random" =>
val userId = UUID.fromString(jwt.getSubject)
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
val userOpt = accountService.findById(userId)
(botOpt, userOpt) match
case (None, _) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Official bot '$botName' not found")).build()
case (_, None) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
case (Some(bot), Some(user)) =>
val userIsWhite = normalizedColor match
case "white" => true
case "black" => false
case _ => ThreadLocalRandom.current().nextBoolean()
val (white, black, botColor) =
if userIsWhite then
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
else
(CorePlayerInfo(bot.id.toString, bot.name), CorePlayerInfo(user.id.toString, user.username), "white")
val req = CoreCreateGameRequest(Some(white), Some(black), None, Some("Authenticated"))
val gameId =
try Right(coreGameClient.createGame(req).gameId)
catch case _ => Left("Failed to create game")
gameId match
case Left(err) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
case Right(id) =>
try botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
Response
.status(Response.Status.CREATED)
.entity(OfficialChallengeResponse(id, botName, difficulty))
.build()
case other =>
Response
.status(Response.Status.BAD_REQUEST)
.entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
.build()
@@ -0,0 +1,165 @@
package de.nowchess.account.service
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
import de.nowchess.account.error.AccountError
import de.nowchess.account.repository.{BotAccountRepository, OfficialBotAccountRepository, UserAccountRepository}
import io.quarkus.elytron.security.common.BcryptUtil
import io.smallrye.jwt.build.Jwt
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import scala.compiletime.uninitialized
import java.time.Instant
import java.util.UUID
@ApplicationScoped
class AccountService:
// scalafix:off DisableSyntax.var
@Inject
var userAccountRepository: UserAccountRepository = uninitialized
@Inject
var botAccountRepository: BotAccountRepository = uninitialized
@Inject
var officialBotAccountRepository: OfficialBotAccountRepository = uninitialized
// scalafix:on
@Transactional
def register(req: RegisterRequest): Either[AccountError, UserAccount] =
if userAccountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username))
else if userAccountRepository.findByEmail(req.email).isDefined then
Left(AccountError.EmailAlreadyRegistered(req.email))
else
val account = new UserAccount()
account.username = req.username
account.email = req.email
account.passwordHash = BcryptUtil.bcryptHash(req.password)
account.createdAt = Instant.now()
userAccountRepository.persist(account)
Right(account)
def login(req: LoginRequest): Either[AccountError, String] =
userAccountRepository.findByUsername(req.username) match
case None => Left(AccountError.InvalidCredentials)
case Some(account) =>
if !BcryptUtil.matches(req.password, account.passwordHash) then Left(AccountError.InvalidCredentials)
else if account.banned then Left(AccountError.UserBanned)
else
Right(
Jwt
.issuer("nowchess")
.subject(account.id.toString)
.claim("username", account.username)
.sign(),
)
def findByUsername(username: String): Option[UserAccount] =
userAccountRepository.findByUsername(username)
def findById(id: UUID): Option[UserAccount] =
userAccountRepository.findById(id)
@Transactional
def createBotAccount(ownerId: UUID, botName: String): Either[AccountError, BotAccount] =
userAccountRepository.findById(ownerId) match
case None => Left(AccountError.UserNotFound)
case Some(owner) =>
val botAccounts = botAccountRepository.findByOwner(ownerId)
if botAccounts.length >= 5 then Left(AccountError.BotLimitExceeded)
else
val bot = new BotAccount()
bot.name = botName
bot.owner = owner
bot.token = generateBotToken(bot.id)
bot.createdAt = Instant.now()
botAccountRepository.persist(bot)
Right(bot)
def getBotAccounts(ownerId: UUID): List[BotAccount] =
botAccountRepository.findByOwner(ownerId)
def getBotAccountWithOwnerCheck(botId: UUID, ownerId: UUID): Option[Option[BotAccount]] =
botAccountRepository.findById(botId) match
case None => Some(None)
case Some(bot) => Some(Option(bot).filter(_.owner.id == ownerId))
@Transactional
def deleteBotAccount(botId: UUID): Either[AccountError, Unit] =
botAccountRepository.findById(botId) match
case None => Left(AccountError.BotNotFound)
case Some(_) =>
botAccountRepository.delete(botId)
Right(())
@Transactional
def updateBotName(botId: UUID, ownerId: UUID, newName: String): Either[AccountError, BotAccount] =
botAccountRepository.findById(botId) match
case None => Left(AccountError.BotNotFound)
case Some(bot) =>
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
else
bot.name = newName
botAccountRepository.persist(bot)
Right(bot)
@Transactional
def rotateBotToken(botId: UUID, ownerId: UUID): Either[AccountError, BotAccount] =
botAccountRepository.findById(botId) match
case None => Left(AccountError.BotNotFound)
case Some(bot) =>
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
else
bot.token = generateBotToken(botId)
botAccountRepository.persist(bot)
Right(bot)
@Transactional
def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] =
val bot = new OfficialBotAccount()
bot.name = botName
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
Right(bot)
def getOfficialBotAccounts(): List[OfficialBotAccount] =
officialBotAccountRepository.findAll()
@Transactional
def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] =
officialBotAccountRepository.findById(botId) match
case None => Left(AccountError.BotNotFound)
case Some(_) =>
officialBotAccountRepository.delete(botId)
Right(())
private def generateBotToken(botId: UUID): String =
Jwt
.issuer("nowchess")
.subject(botId.toString)
.expiresAt(Long.MaxValue)
.claim("type", "bot")
.sign()
@Transactional
def banUser(userId: UUID): Either[AccountError, UserAccount] =
userAccountRepository.findById(userId) match
case None => Left(AccountError.UserNotFound)
case Some(user) =>
user.banned = true
user.botAccounts.forEach(_.banned = true)
userAccountRepository.persist(user)
Right(user)
@Transactional
def unbanUser(userId: UUID): Either[AccountError, UserAccount] =
userAccountRepository.findById(userId) match
case None => Left(AccountError.UserNotFound)
case Some(user) =>
user.banned = false
user.botAccounts.forEach(_.banned = false)
userAccountRepository.persist(user)
Right(user)
@@ -0,0 +1,197 @@
package de.nowchess.account.service
import de.nowchess.account.client.{
CoreCreateGameRequest,
CoreGameClient,
CoreGameResponse,
CorePlayerInfo,
CoreTimeControl,
}
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
import de.nowchess.account.dto.{
ChallengeDto,
ChallengeListDto,
ChallengeRequest,
DeclineRequest,
PlayerInfo,
TimeControlDto,
}
import de.nowchess.account.error.ChallengeError
import de.nowchess.account.repository.{ChallengeRepository, UserAccountRepository}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.UUID
import java.util.concurrent.ThreadLocalRandom
@ApplicationScoped
class ChallengeService:
private val log = Logger.getLogger(classOf[ChallengeService])
// scalafix:off DisableSyntax.var
@Inject
var userAccountRepository: UserAccountRepository = uninitialized
@Inject
var challengeRepository: ChallengeRepository = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
@Inject
var eventPublisher: EventPublisher = uninitialized
// scalafix:on
@Transactional
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
for
destUser <- userAccountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
challenger <- userAccountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
_ <- Either.cond(
challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
(),
ChallengeError.DuplicateChallenge,
)
color <- parseColor(req.color)
yield
val challenge = new Challenge()
challenge.challenger = challenger
challenge.destUser = destUser
challenge.color = color
challenge.status = ChallengeStatus.Created
challenge.timeControlType = req.timeControl.`type`
challenge.timeControlLimit = req.timeControl.limit.map(java.lang.Integer.valueOf).orNull
challenge.timeControlIncrement = req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
challenge.createdAt = Instant.now()
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
challengeRepository.persist(challenge)
try eventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
challenge
@Transactional
def accept(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
gameId <- createGame(challenge)
yield
challenge.status = ChallengeStatus.Accepted
challenge.gameId = gameId
challengeRepository.merge(challenge)
notifyBotIfNeeded(challenge, gameId)
try eventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
challenge
@Transactional
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
reason <- parseDeclineReason(req.reason)
yield
challenge.status = ChallengeStatus.Declined
challenge.declineReason = reason.orNull
challengeRepository.merge(challenge)
challenge
@Transactional
def cancel(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
_ <- Either.cond(challenge.challenger.id == userId, (), ChallengeError.NotAuthorized)
yield
challenge.status = ChallengeStatus.Canceled
challengeRepository.merge(challenge)
challenge
def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(
challenge.challenger.id == userId || challenge.destUser.id == userId,
(),
ChallengeError.NotAuthorized,
)
yield challenge
def listForUser(userId: UUID): ChallengeListDto =
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
ChallengeListDto(in = incoming, out = outgoing)
private def notifyBotIfNeeded(challenge: Challenge, gameId: String): Unit =
val (white, black) = assignColors(challenge)
List(challenge.challenger, challenge.destUser).foreach { user =>
user.getBotAccounts.headOption.foreach { bot =>
val playingAs = if white.id == user.id.toString then "white" else "black"
try eventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
}
}
private def createGame(challenge: Challenge): Either[ChallengeError, String] =
try
val (white, black) = assignColors(challenge)
val tc = buildTimeControl(challenge)
val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
Right(coreGameClient.createGame(req).gameId)
catch case _ => Left(ChallengeError.GameCreationFailed)
private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username)
val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username)
challenge.color match
case ChallengeColor.White => (challenger, destUser)
case ChallengeColor.Black => (destUser, challenger)
case ChallengeColor.Random =>
if ThreadLocalRandom.current().nextBoolean() then (challenger, destUser) else (destUser, challenger)
private def buildTimeControl(challenge: Challenge): Option[CoreTimeControl] =
challenge.timeControlType match
case "unlimited" => None
case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt))
case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None))
private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] =
raw.toLowerCase match
case "white" => Right(ChallengeColor.White)
case "black" => Right(ChallengeColor.Black)
case "random" => Right(ChallengeColor.Random)
case _ => Left(ChallengeError.InvalidColor(raw))
private def parseDeclineReason(raw: Option[String]): Either[ChallengeError, Option[DeclineReason]] =
raw match
case None => Right(None)
case Some(r) =>
DeclineReason.values.find(_.toString.equalsIgnoreCase(r)) match
case Some(reason) => Right(Some(reason))
case None => Left(ChallengeError.InvalidDeclineReason(r))
def toDto(c: Challenge): ChallengeDto =
ChallengeDto(
id = c.id.toString,
challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating),
destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating),
variant = "standard",
color = c.color.toString.toLowerCase,
timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
status = c.status.toString.toLowerCase,
declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
gameId = c.gameIdOpt,
createdAt = c.createdAt.toString,
expiresAt = c.expiresAt.toString,
)
@@ -0,0 +1,31 @@
package de.nowchess.account.service
import de.nowchess.account.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import scala.compiletime.uninitialized
@ApplicationScoped
class EventPublisher:
// scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = 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)
()
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)
()
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)
()
@@ -0,0 +1,36 @@
quarkus:
http:
port: 8083
application:
name: nowchess-account
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
swagger-ui:
always-include: true
path: /swagger-ui
datasource:
db-kind: h2
username: sa
password: ""
jdbc:
url: "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
hibernate-orm:
schema-management:
strategy: drop-and-create
mp:
jwt:
verify:
publickey:
location: keys/test-public.pem
issuer: nowchess
smallrye:
jwt:
sign:
key:
location: keys/test-private.pem
nowchess:
internal:
secret: test-secret
auth:
enabled: false
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6
wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X
PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB
oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0
jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH
GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK
FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD
ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D
wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa
UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz
3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC
B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f
Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P
GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j
cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy
aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw
ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6
DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd
0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1
TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4
8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r
G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13
clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E
wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r
kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew
5i4ReFwm3/IWDn9kLmHT6Fg=
-----END PRIVATE KEY-----
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y
VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V
MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW
WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB
QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X
FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ
BwIDAQAB
-----END PUBLIC KEY-----
@@ -0,0 +1,107 @@
package de.nowchess.account.resource
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import io.restassured.http.ContentType
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.Test
@QuarkusTest
class AccountResourceTest:
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
private def registerBody(username: String, email: String = "", password: String = "secret") =
val resolvedEmail = if email.isEmpty then s"$username@example.com" else email
s"""{"username":"$username","email":"$resolvedEmail","password":"$password"}"""
private def loginBody(username: String, password: String = "secret") =
s"""{"username":"$username","password":"$password"}"""
private def registerAndLogin(username: String): String =
givenRequest()
.body(registerBody(username))
.when()
.post("/api/account")
.`then`()
.statusCode(200)
givenRequest()
.body(loginBody(username))
.when()
.post("/api/account/login")
.`then`()
.statusCode(200)
.extract()
.path[String]("token")
@Test
def registerReturns200(): Unit =
givenRequest()
.body(registerBody("alice"))
.when()
.post("/api/account")
.`then`()
.statusCode(200)
.body("username", is("alice"))
.body("rating", is(1500))
@Test
def registerConflictOnDuplicateUsername(): Unit =
givenRequest().body(registerBody("bob")).when().post("/api/account")
givenRequest()
.body(registerBody("bob"))
.when()
.post("/api/account")
.`then`()
.statusCode(409)
.body("error", containsString("bob"))
@Test
def loginReturns200WithToken(): Unit =
givenRequest().body(registerBody("charlie")).when().post("/api/account")
givenRequest()
.body(loginBody("charlie"))
.when()
.post("/api/account/login")
.`then`()
.statusCode(200)
.body("token", notNullValue())
@Test
def loginUnauthorizedOnWrongPassword(): Unit =
givenRequest().body(registerBody("dave")).when().post("/api/account")
givenRequest()
.body(loginBody("dave", "wrongpassword"))
.when()
.post("/api/account/login")
.`then`()
.statusCode(401)
@Test
def getMeReturns200(): Unit =
val token = registerAndLogin("eve")
givenRequest()
.header("Authorization", s"Bearer $token")
.when()
.get("/api/account/me")
.`then`()
.statusCode(200)
.body("username", is("eve"))
@Test
def getPublicProfileReturns200(): Unit =
givenRequest().body(registerBody("frank")).when().post("/api/account")
givenRequest()
.when()
.get("/api/account/frank")
.`then`()
.statusCode(200)
.body("username", is("frank"))
@Test
def getPublicProfileNotFound(): Unit =
givenRequest()
.when()
.get("/api/account/doesnotexist")
.`then`()
.statusCode(404)
@@ -0,0 +1,179 @@
package de.nowchess.account.resource
import de.nowchess.account.client.{CoreGameClient, CoreGameResponse}
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import io.restassured.http.ContentType
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.{BeforeEach, Test}
import org.mockito.{ArgumentMatchers, Mockito}
@QuarkusTest
class ChallengeResourceTest:
@InjectMock
@RestClient
// scalafix:off DisableSyntax.var
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
// scalafix:on
@BeforeEach
def setup(): Unit =
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("test-game-id"))
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
private def registerBody(username: String, suffix: String = "") =
val email = s"$username$suffix@test.com"
s"""{"username":"$username$suffix","email":"$email","password":"secret"}"""
private def loginBody(username: String, suffix: String = "") =
s"""{"username":"$username$suffix","password":"secret"}"""
private def registerAndLogin(username: String, suffix: String = ""): String =
givenRequest().body(registerBody(username, suffix)).when().post("/api/account")
givenRequest()
.body(loginBody(username, suffix))
.when()
.post("/api/account/login")
.`then`()
.statusCode(200)
.extract()
.path[String]("token")
private val clockBody =
"""{"color":"random","timeControl":{"type":"clock","limit":300,"increment":5}}"""
private def authed(token: String) =
givenRequest().header("Authorization", s"Bearer $token")
@Test
def createChallengeReturns201(): Unit =
val t1 = registerAndLogin("user1c")
registerAndLogin("user2c")
authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/user2c")
.`then`()
.statusCode(201)
.body("status", is("created"))
.body("color", is("random"))
@Test
def createChallengeConflictOnDuplicate(): Unit =
val t1 = registerAndLogin("user1dup")
registerAndLogin("user2dup")
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/user2dup")
authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/user2dup")
.`then`()
.statusCode(409)
@Test
def createChallengeSelfForbidden(): Unit =
val token = registerAndLogin("selfuser")
authed(token)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/selfuser")
.`then`()
.statusCode(400)
@Test
def acceptChallengeReturns200(): Unit =
val t1 = registerAndLogin("accUser1")
val t2 = registerAndLogin("accUser2")
val challengeId = authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/accUser2")
.`then`()
.statusCode(201)
.extract()
.path[String]("id")
authed(t2)
.when()
.post(s"/api/challenge/$challengeId/accept")
.`then`()
.statusCode(200)
.body("status", is("accepted"))
.body("gameId", is("test-game-id"))
@Test
def declineChallengeReturns200(): Unit =
val t1 = registerAndLogin("decUser1")
val t2 = registerAndLogin("decUser2")
val challengeId = authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/decUser2")
.`then`()
.statusCode(201)
.extract()
.path[String]("id")
authed(t2)
.contentType(ContentType.JSON)
.body("""{"reason":"later"}""")
.when()
.post(s"/api/challenge/$challengeId/decline")
.`then`()
.statusCode(200)
.body("status", is("declined"))
.body("declineReason", is("later"))
@Test
def cancelChallengeReturns200(): Unit =
val t1 = registerAndLogin("canUser1")
registerAndLogin("canUser2")
val challengeId = authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/canUser2")
.`then`()
.statusCode(201)
.extract()
.path[String]("id")
authed(t1)
.when()
.post(s"/api/challenge/$challengeId/cancel")
.`then`()
.statusCode(200)
.body("status", is("canceled"))
@Test
def listChallengesReturnsInAndOut(): Unit =
val t1 = registerAndLogin("listUser1")
registerAndLogin("listUser2")
registerAndLogin("listUser3")
authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/listUser2")
.`then`()
.statusCode(201)
authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/listUser3")
.`then`()
.statusCode(201)
authed(t1)
.when()
.get("/api/challenge")
.`then`()
.statusCode(200)
.body("out.size()", is(2))
.body("in.size()", is(0))