From 3718f7669abd126cd5fa76e706a860c9ec8583e7 Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Tue, 16 Jun 2026 08:40:14 +0200 Subject: [PATCH] feat(tournament): add external server registry and official-bot bridge TournamentServerRegistry holds multiple external tournament servers in memory; ExternalTournamentClient proxies all read/write/stream calls to them. TournamentResource fans out list() across registered servers and routes per-tournament calls to the owning server via a tournamentId cache. TournamentServerResource exposes GET/POST/DELETE /api/tournament/servers. Official-bot bridge: TournamentBotGamePlayer gains a runtime joinTournament API (registers fresh bot identity, starts background stream loop). TournamentJoinResource exposes POST /api/bots/official/join-tournament so the UI can add official bots without a server restart. Co-Authored-By: Claude Sonnet 4.6 --- .../bot/resource/JoinTournamentRequest.scala | 7 + .../bot/resource/JoinTournamentResponse.scala | 7 + .../bot/resource/TournamentJoinResource.scala | 44 +++++ .../bot/service/TournamentBotGamePlayer.scala | 116 +++++++----- .../config/NativeReflectionConfig.scala | 3 + .../tournament/dto/ExternalServer.scala | 5 + .../resource/TournamentResource.scala | 173 +++++++++++++++--- .../resource/TournamentServerResource.scala | 35 ++++ .../service/ExternalTournamentClient.scala | 80 ++++++++ .../service/TournamentServerRegistry.scala | 34 ++++ 10 files changed, 437 insertions(+), 67 deletions(-) create mode 100644 modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentRequest.scala create mode 100644 modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentResponse.scala create mode 100644 modules/official-bots/src/main/scala/de/nowchess/bot/resource/TournamentJoinResource.scala create mode 100644 modules/tournament/src/main/scala/de/nowchess/tournament/dto/ExternalServer.scala create mode 100644 modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentServerResource.scala create mode 100644 modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala create mode 100644 modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentServerRegistry.scala diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentRequest.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentRequest.scala new file mode 100644 index 0000000..04c3d40 --- /dev/null +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentRequest.scala @@ -0,0 +1,7 @@ +package de.nowchess.bot.resource + +case class JoinTournamentRequest( + tournamentId: String, + difficulty: String, + serverUrl: Option[String], +) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentResponse.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentResponse.scala new file mode 100644 index 0000000..053cfb3 --- /dev/null +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/JoinTournamentResponse.scala @@ -0,0 +1,7 @@ +package de.nowchess.bot.resource + +case class JoinTournamentResponse( + botId: String, + difficulty: String, + status: String, +) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/resource/TournamentJoinResource.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/TournamentJoinResource.scala new file mode 100644 index 0000000..0b8fcbd --- /dev/null +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/TournamentJoinResource.scala @@ -0,0 +1,44 @@ +package de.nowchess.bot.resource + +import de.nowchess.bot.service.TournamentBotGamePlayer +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.jboss.logging.Logger +import scala.compiletime.uninitialized + +@Path("/api/bots/official") +@ApplicationScoped +@RolesAllowed(Array("**")) +@Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) +class TournamentJoinResource: + + private val log = Logger.getLogger(classOf[TournamentJoinResource]) + + // scalafix:off DisableSyntax.var + @Inject var player: TournamentBotGamePlayer = uninitialized + // scalafix:on DisableSyntax.var + + @POST + @Path("/join-tournament") + def joinTournament(req: JoinTournamentRequest): Response = + val serverUrl = req.serverUrl.filter(_.nonEmpty).getOrElse(player.defaultServerUrl) + val difficulty = if req.difficulty.nonEmpty then req.difficulty else "medium" + log.infof( + "Official bot join requested — tournament=%s difficulty=%s server=%s", + req.tournamentId, + difficulty, + serverUrl, + ) + player.joinTournament(req.tournamentId, difficulty, serverUrl) match + case Right(botId) => + val resp = JoinTournamentResponse(botId, difficulty, "joining") + Response.ok(resp).build() + case Left(err) => + Response + .status(Response.Status.BAD_GATEWAY) + .entity(s"""{"error":"$err"}""") + .build() diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala index 6c29e25..117d74a 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala @@ -38,6 +38,9 @@ class TournamentBotGamePlayer: @volatile private var running = true // scalafix:on DisableSyntax.var + val defaultServerUrl: String = + System.getenv().asScala.getOrElse("TOURNAMENT_SERVER_URL", "http://localhost:8089") + @PostConstruct def initialize(): Unit = config match @@ -45,9 +48,42 @@ class TournamentBotGamePlayer: log.info("Tournament bot disabled — set TOURNAMENT_ID and TOURNAMENT_BOT_TOKEN to enable") case Some(cfg) => log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId) - val thread = new Thread(() => connect(cfg), s"TournamentBot-${cfg.tournamentId}") - thread.setDaemon(true) - thread.start() + startAsync(cfg) + + def joinTournament(tournamentId: String, difficulty: String, serverUrl: String): Either[String, String] = + registerBot(serverUrl, difficulty) match + case None => Left("Failed to register bot with tournament server") + case Some((botId, token)) => + val cfg = TournamentBotConfig(serverUrl, tournamentId, token, botId, difficulty) + if join(cfg) then + startAsync(cfg) + Right(botId) + else Left("Failed to join tournament") + + private def startAsync(cfg: TournamentBotConfig): Unit = + val thread = new Thread(() => streamLoop(cfg), s"TournamentBot-${cfg.tournamentId}") + thread.setDaemon(true) + thread.start() + + private def registerBot(serverUrl: String, difficulty: String): Option[(String, String)] = + Try { + val name = s"NowChess ${difficulty.capitalize}" + val body = s"""{"name":"$name","isBot":true}""" + val response = client + .target(serverUrl) + .path("api") + .path("auth") + .path("register") + .request(MediaType.APPLICATION_JSON) + .post(Entity.entity(body, MediaType.APPLICATION_JSON)) + if response.getStatus == 201 then + val node = objectMapper.readTree(response.readEntity(classOf[String])) + val id = node.path("id").asText() + val token = node.path("token").asText() + response.close() + if id.nonEmpty && token.nonEmpty then Some((id, token)) else None + else { log.warnf("Bot registration returned status %d", response.getStatus); response.close(); None } + }.getOrElse(None) @PreDestroy def cleanup(): Unit = @@ -56,12 +92,11 @@ class TournamentBotGamePlayer: Try(client.close()) log.info("Tournament bot stopped") - private def connect(cfg: TournamentBotConfig): Unit = - if join(cfg) then - while running do - Try(streamEvents(cfg)) match - case Failure(ex) => log.warnf(ex, "Tournament event stream dropped — reconnecting"); sleep(5000) - case Success(_) => sleep(2000) + private def streamLoop(cfg: TournamentBotConfig): Unit = + while running do + Try(streamEvents(cfg)) match + case Failure(ex) => log.warnf(ex, "Tournament event stream dropped — reconnecting"); sleep(5000) + case Success(_) => sleep(2000) private def join(cfg: TournamentBotConfig): Boolean = Try { @@ -86,41 +121,23 @@ class TournamentBotGamePlayer: log.infof("Listening to tournament %s event stream", cfg.tournamentId) forEachLine(response.readEntity(classOf[InputStream])): line => parse(line).foreach: node => - if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText()) + if node.path("type").asText() == "gameStart" then + onGameStart(cfg, node.path("gameId").asText(), node.path("color").asText()) - private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit = - if gameId.nonEmpty && activeGames.add(gameId) then - workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId) }) + private def onGameStart(cfg: TournamentBotConfig, gameId: String, color: String): Unit = + if gameId.nonEmpty && color.nonEmpty && activeGames.add(gameId) then + workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId, color) }) () - private def playGame(cfg: TournamentBotConfig, gameId: String): Unit = + private def playGame(cfg: TournamentBotConfig, gameId: String, color: String): Unit = Try { - colorFor(cfg, gameId) match - case None => - log.debugf("Game %s is not ours — ignoring", gameId) - activeGames.remove(gameId) - case Some(color) => - log.infof("Playing game %s as %s", gameId, color) - val stream = openGameStream(cfg, gameId) - maybeMoveFromCurrentState(cfg, gameId, color) - stream.foreach(consumeGameStream(cfg, gameId, color, _)) - activeGames.remove(gameId) + log.infof("Playing game %s as %s", gameId, color) + openGameStream(cfg, gameId).foreach(consumeGameStream(cfg, gameId, color, _)) + activeGames.remove(gameId) } match case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId) case Success(_) => () - private def colorFor(cfg: TournamentBotConfig, gameId: String): Option[String] = - fetchGame(cfg, gameId).flatMap: game => - val white = game.path("white").path("id").asText() - val black = game.path("black").path("id").asText() - if white == cfg.botId then Some("white") - else if black == cfg.botId then Some("black") - else None - - private def maybeMoveFromCurrentState(cfg: TournamentBotConfig, gameId: String, color: String): Unit = - fetchGame(cfg, gameId).foreach: game => - maybeMove(cfg, gameId, color, game.path("turn").asText(), game.path("status").asText(), game.path("fen").asText()) - private def consumeGameStream(cfg: TournamentBotConfig, gameId: String, color: String, stream: InputStream): Unit = val reader = new BufferedReader(new InputStreamReader(stream)) // scalafix:off DisableSyntax.var @@ -134,10 +151,25 @@ class TournamentBotGamePlayer: .foreach { line => parse(line).foreach: node => node.path("type").asText() match + case "gameState" => + maybeMove( + cfg, + gameId, + color, + node.path("turn").asText(), + node.path("status").asText(), + node.path("fen").asText(), + ) case "move" => maybeMove(cfg, gameId, color, node.path("turn").asText(), "ongoing", node.path("fen").asText()) - case "gameEnd" => log.infof("Game %s ended — status=%s", gameId, node.path("status").asText()); done = true - case _ => () + case "gameEnd" => + log.infof( + "Game %s ended — status=%s winner=%s", + gameId, + node.path("status").asText(), + node.path("winner").asText(), + ); done = true + case _ => () } private def maybeMove( @@ -169,14 +201,6 @@ class TournamentBotGamePlayer: case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId) case Success(_) => () - private def fetchGame(cfg: TournamentBotConfig, gameId: String): Option[JsonNode] = - Try { - val response = target(cfg).path("game").path(gameId).request(MediaType.APPLICATION_JSON).get() - val node = if response.getStatus == 200 then Some(response.readEntity(classOf[JsonNode])) else None - response.close() - node - }.getOrElse(None) - private def openGameStream(cfg: TournamentBotConfig, gameId: String): Option[InputStream] = Try { val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream")) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/config/NativeReflectionConfig.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/config/NativeReflectionConfig.scala index 925c237..bb6689d 100644 --- a/modules/tournament/src/main/scala/de/nowchess/tournament/config/NativeReflectionConfig.scala +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/config/NativeReflectionConfig.scala @@ -31,6 +31,9 @@ import io.quarkus.runtime.annotations.RegisterForReflection classOf[CoreCreateGameRequest], classOf[CoreGameResponse], classOf[GameWritebackEventDto], + classOf[ExternalTournamentServer], + classOf[RegisterServerRequest], + classOf[ExternalTournamentServerList], ), ) class NativeReflectionConfig diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/dto/ExternalServer.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/dto/ExternalServer.scala new file mode 100644 index 0000000..616cd5f --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/dto/ExternalServer.scala @@ -0,0 +1,5 @@ +package de.nowchess.tournament.dto + +case class ExternalTournamentServer(id: String, label: String, url: String) +case class RegisterServerRequest(label: String, url: String) +case class ExternalTournamentServerList(servers: List[ExternalTournamentServer]) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala index 9925812..38b8a2f 100644 --- a/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala @@ -1,17 +1,24 @@ package de.nowchess.tournament.resource +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} import de.nowchess.tournament.dto.* import de.nowchess.tournament.error.TournamentError -import de.nowchess.tournament.service.{TournamentService, TournamentStreamManager} +import de.nowchess.tournament.service.{ + ExternalTournamentClient, + TournamentServerRegistry, + TournamentService, + TournamentStreamManager, +} import io.smallrye.mutiny.Multi import jakarta.annotation.security.{PermitAll, RolesAllowed} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import jakarta.ws.rs.* -import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response} +import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response, StreamingOutput} import org.eclipse.microprofile.jwt.JsonWebToken import org.jboss.logging.Logger import scala.compiletime.uninitialized +import scala.jdk.CollectionConverters.* @Path("/api/tournament") @ApplicationScoped @@ -22,21 +29,48 @@ class TournamentResource: private val log = Logger.getLogger(classOf[TournamentResource]) // scalafix:off DisableSyntax.var - @Inject var tournamentService: TournamentService = uninitialized - @Inject var streamManager: TournamentStreamManager = uninitialized - @Inject var jwt: JsonWebToken = uninitialized + @Inject var tournamentService: TournamentService = uninitialized + @Inject var streamManager: TournamentStreamManager = uninitialized + @Inject var jwt: JsonWebToken = uninitialized + @Inject var registry: TournamentServerRegistry = uninitialized + @Inject var externalClient: ExternalTournamentClient = uninitialized + @Inject var objectMapper: ObjectMapper = uninitialized + @Context var headers: HttpHeaders = uninitialized // scalafix:on @GET @PermitAll def list(): Response = val (created, started, finished) = tournamentService.list() - val dto = TournamentListDto( - created = created.map(t => tournamentService.toDto(t)), - started = started.map(t => tournamentService.toDto(t)), - finished = finished.map(t => tournamentService.toDto(t)), - ) - Response.ok(dto).build() + val internalCreated = created.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t))) + val internalStarted = started.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t))) + val internalFinished = finished.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t))) + + val (extCreated, extStarted, extFinished) = registry + .serverUrls() + .foldLeft( + (List.empty[JsonNode], List.empty[JsonNode], List.empty[JsonNode]), + ) { case ((ac, as, af), url) => + externalClient.fetchList(url).fold((ac, as, af)) { node => + val c = node.path("created").elements().asScala.toList + val s = node.path("started").elements().asScala.toList + val f = node.path("finished").elements().asScala.toList + (c ++ s ++ f).foreach(t => registry.bindTournament(t.path("id").asText(), url)) + (ac ++ c, as ++ s, af ++ f) + } + } + + val merged = objectMapper.createObjectNode() + val createdArr = objectMapper.createArrayNode() + val startedArr = objectMapper.createArrayNode() + val finishedArr = objectMapper.createArrayNode() + (internalCreated ++ extCreated).foreach(createdArr.add) + (internalStarted ++ extStarted).foreach(startedArr.add) + (internalFinished ++ extFinished).foreach(finishedArr.add) + merged.set("created", createdArr) + merged.set("started", startedArr) + merged.set("finished", finishedArr) + Response.ok(merged).build() @POST @RolesAllowed(Array("**")) @@ -58,10 +92,13 @@ class TournamentResource: @PermitAll def get(@PathParam("id") id: String): Response = tournamentService.get(id) match - case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build() case Some(t) => val standings = tournamentService.getStandings(id) Response.ok(tournamentService.toDto(t, standings)).build() + case None => + resolveServer(id) + .flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build())) + .getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()) @DELETE @Path("/{id}") @@ -78,8 +115,18 @@ class TournamentResource: def start(@PathParam("id") id: String): Response = val userId = Option(jwt.getSubject).getOrElse("") tournamentService.start(id, userId) match - case Right(t) => Response.ok(tournamentService.toDto(t)).build() - case Left(error) => errorResponse(error) + case Right(t) => Response.ok(tournamentService.toDto(t)).build() + case Left(error) => + error match + case TournamentError.NotFound(_) => + val auth = Option(headers.getHeaderString("Authorization")) + resolveServer(id) + .map { url => + val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth) + Response.status(status).entity(body).build() + } + .getOrElse(errorResponse(error)) + case _ => errorResponse(error) @POST @Path("/{id}/join") @@ -92,8 +139,18 @@ class TournamentResource: val botId = Option(jwt.getSubject).getOrElse("") val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId) tournamentService.join(id, botId, botName) match - case Right(_) => Response.ok(OkDto()).build() - case Left(error) => errorResponse(error) + case Right(_) => Response.ok(OkDto()).build() + case Left(error) => + error match + case TournamentError.NotFound(_) => + val auth = Option(headers.getHeaderString("Authorization")) + resolveServer(id) + .map { url => + val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/join", auth) + Response.status(status).entity(body).build() + } + .getOrElse(errorResponse(error)) + case _ => errorResponse(error) @POST @Path("/{id}/withdraw") @@ -105,8 +162,18 @@ class TournamentResource: else val botId = Option(jwt.getSubject).getOrElse("") tournamentService.withdraw(id, botId) match - case Right(_) => Response.ok(OkDto()).build() - case Left(error) => errorResponse(error) + case Right(_) => Response.ok(OkDto()).build() + case Left(error) => + error match + case TournamentError.NotFound(_) => + val auth = Option(headers.getHeaderString("Authorization")) + resolveServer(id) + .map { url => + val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/withdraw", auth) + Response.status(status).entity(body).build() + } + .getOrElse(errorResponse(error)) + case _ => errorResponse(error) @GET @Path("/{id}/results") @@ -133,20 +200,23 @@ class TournamentResource: @PermitAll def roundPairings(@PathParam("id") id: String, @PathParam("round") round: Int): Response = tournamentService.get(id) match - case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build() case Some(_) => val pairings = tournamentService.getPairings(id, round) Response.ok(RoundPairingsDto(round, pairings)).build() + case None => + resolveServer(id) + .flatMap(url => externalClient.fetchPairings(url, id, round).map(node => Response.ok(node).build())) + .getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()) @GET @Path("/{id}/export/games") @PermitAll @Produces(Array(MediaType.APPLICATION_JSON, MediaType.WILDCARD, "application/x-ndjson", "application/x-chess-pgn")) - def exportGames(@PathParam("id") id: String, @Context headers: HttpHeaders): Response = + def exportGames(@PathParam("id") id: String, @Context reqHeaders: HttpHeaders): Response = tournamentService.get(id) match case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build() case Some(_) => - val acceptHeader = Option(headers.getHeaderString("Accept")).getOrElse("") + val acceptHeader = Option(reqHeaders.getHeaderString("Accept")).getOrElse("") val pairings = tournamentService.getAllPairings(id) if acceptHeader.contains("application/x-ndjson") then val ndjson = pairings @@ -176,6 +246,67 @@ class TournamentResource: emitter.onTermination(() => streamManager.unregister(id, botId, emitter)) } + @GET + @Path("/{id}/game/{gameId}") + @PermitAll + def getGame(@PathParam("id") id: String, @PathParam("gameId") gameId: String): Response = + resolveServer(id) + .flatMap(url => externalClient.fetch(url, s"$id/game/$gameId").map(node => Response.ok(node).build())) + .getOrElse(Response.status(Response.Status.NOT_FOUND).build()) + + @GET + @Path("/{id}/game/{gameId}/stream") + @PermitAll + @Produces(Array("application/x-ndjson")) + def streamGame(@PathParam("id") id: String, @PathParam("gameId") gameId: String): Response = + val auth = Option(headers.getHeaderString("Authorization")) + resolveServer(id) + .flatMap(url => externalClient.proxyGetStream(url, s"api/tournament/$id/game/$gameId/stream", auth)) + .map { stream => + Response + .ok(new StreamingOutput { + def write(output: java.io.OutputStream): Unit = + val buf = new Array[Byte](4096) + // scalafix:off DisableSyntax.var + var n = stream.read(buf) + while n >= 0 do + output.write(buf, 0, n) + output.flush() + n = stream.read(buf) + // scalafix:on + }) + .`type`("application/x-ndjson") + .build() + } + .getOrElse(Response.status(Response.Status.NOT_FOUND).build()) + + @POST + @Path("/{id}/game/{gameId}/move/{uci}") + @RolesAllowed(Array("**")) + def makeMove( + @PathParam("id") id: String, + @PathParam("gameId") gameId: String, + @PathParam("uci") uci: String, + ): Response = + val auth = Option(headers.getHeaderString("Authorization")) + resolveServer(id) + .map { url => + val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/game/$gameId/move/$uci", auth) + Response.status(status).entity(body).build() + } + .getOrElse(Response.status(Response.Status.NOT_FOUND).build()) + + private def resolveServer(tournamentId: String): Option[String] = + registry.findServerUrl(tournamentId).orElse { + registry + .serverUrls() + .find(url => externalClient.fetch(url, tournamentId).isDefined) + .map { url => + registry.bindTournament(tournamentId, url) + url + } + } + private def errorResponse(error: TournamentError): Response = val status = error match case TournamentError.NotFound(_) => Response.Status.NOT_FOUND diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentServerResource.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentServerResource.scala new file mode 100644 index 0000000..349b3be --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentServerResource.scala @@ -0,0 +1,35 @@ +package de.nowchess.tournament.resource + +import de.nowchess.tournament.dto.{ErrorDto, RegisterServerRequest} +import de.nowchess.tournament.service.TournamentServerRegistry +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 scala.compiletime.uninitialized + +@Path("/api/tournament/servers") +@ApplicationScoped +@RolesAllowed(Array("**")) +@Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) +class TournamentServerResource: + + // scalafix:off DisableSyntax.var + @Inject var registry: TournamentServerRegistry = uninitialized + // scalafix:on + + @GET + def list(): Response = + Response.ok(registry.list()).build() + + @POST + def register(req: RegisterServerRequest): Response = + Response.status(201).entity(registry.register(req.label, req.url)).build() + + @DELETE + @Path("/{id}") + def remove(@PathParam("id") id: String): Response = + if registry.remove(id) then Response.noContent().build() + else Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Server $id not found")).build() diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala new file mode 100644 index 0000000..c21f1be --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala @@ -0,0 +1,80 @@ +package de.nowchess.tournament.service + +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.client.{Client, ClientBuilder, Entity} +import jakarta.ws.rs.core.MediaType +import scala.compiletime.uninitialized +import scala.util.Try + +@ApplicationScoped +class ExternalTournamentClient: + + // scalafix:off DisableSyntax.var + @Inject var objectMapper: ObjectMapper = uninitialized + // scalafix:on + + private def buildClient(): Client = ClientBuilder.newClient() + + def fetchList(serverUrl: String): Option[JsonNode] = + Try { + val client = buildClient() + val response = client.target(s"$serverUrl/api/tournament").request(MediaType.APPLICATION_JSON).get() + try + if response.getStatus == 200 then Some(objectMapper.readTree(response.readEntity(classOf[String]))) + else None + finally + response.close() + client.close() + }.getOrElse(None) + + def fetch(serverUrl: String, id: String): Option[JsonNode] = + Try { + val client = buildClient() + val response = client.target(s"$serverUrl/api/tournament/$id").request(MediaType.APPLICATION_JSON).get() + try + if response.getStatus == 200 then Some(objectMapper.readTree(response.readEntity(classOf[String]))) + else None + finally + response.close() + client.close() + }.getOrElse(None) + + def fetchPairings(serverUrl: String, id: String, round: Int): Option[JsonNode] = + Try { + val client = buildClient() + val response = + client.target(s"$serverUrl/api/tournament/$id/round/$round").request(MediaType.APPLICATION_JSON).get() + try + if response.getStatus == 200 then Some(objectMapper.readTree(response.readEntity(classOf[String]))) + else None + finally + response.close() + client.close() + }.getOrElse(None) + + def proxyPost(serverUrl: String, path: String, authHeader: Option[String]): (Int, String) = + Try { + val client = buildClient() + val builder = client.target(s"$serverUrl/$path").request(MediaType.APPLICATION_JSON) + val withAuth = authHeader.fold(builder)(h => builder.header("Authorization", h)) + val response = withAuth.post(Entity.json("")) + try (response.getStatus, response.readEntity(classOf[String])) + finally + response.close() + client.close() + }.getOrElse((502, """{"error":"External server unreachable"}""")) + + def proxyGetStream(serverUrl: String, path: String, authHeader: Option[String]): Option[java.io.InputStream] = + Try { + val client = buildClient() + val builder = client.target(s"$serverUrl/$path").request("application/x-ndjson") + val withAuth = authHeader.fold(builder)(h => builder.header("Authorization", h)) + val response = withAuth.get() + if response.getStatus == 200 then Some(response.readEntity(classOf[java.io.InputStream])) + else + response.close() + client.close() + None + }.getOrElse(None) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentServerRegistry.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentServerRegistry.scala new file mode 100644 index 0000000..6f9d02e --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentServerRegistry.scala @@ -0,0 +1,34 @@ +package de.nowchess.tournament.service + +import de.nowchess.tournament.dto.ExternalTournamentServer +import jakarta.enterprise.context.ApplicationScoped +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import scala.jdk.CollectionConverters.* + +@ApplicationScoped +class TournamentServerRegistry: + + private val servers = new ConcurrentHashMap[String, ExternalTournamentServer]() + private val tournaments = new ConcurrentHashMap[String, String]() + + def register(label: String, url: String): ExternalTournamentServer = + val id = UUID.randomUUID().toString + val server = ExternalTournamentServer(id, label, url.stripSuffix("/")) + servers.put(id, server) + server + + def list(): List[ExternalTournamentServer] = + servers.values().asScala.toList + + def remove(id: String): Boolean = + Option(servers.remove(id)).isDefined + + def serverUrls(): List[String] = + servers.values().asScala.map(_.url).toList + + def bindTournament(tournamentId: String, serverUrl: String): Unit = + tournaments.put(tournamentId, serverUrl) + + def findServerUrl(tournamentId: String): Option[String] = + Option(tournaments.get(tournamentId))