feat(tournament): add external server registry and official-bot bridge
Build & Test (NowChessSystems) TeamCity build finished

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 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-16 08:40:14 +02:00
parent 0e0ea4c989
commit 3718f7669a
10 changed files with 437 additions and 67 deletions
@@ -0,0 +1,7 @@
package de.nowchess.bot.resource
case class JoinTournamentRequest(
tournamentId: String,
difficulty: String,
serverUrl: Option[String],
)
@@ -0,0 +1,7 @@
package de.nowchess.bot.resource
case class JoinTournamentResponse(
botId: String,
difficulty: String,
status: String,
)
@@ -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()
@@ -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"))
@@ -31,6 +31,9 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[CoreCreateGameRequest],
classOf[CoreGameResponse],
classOf[GameWritebackEventDto],
classOf[ExternalTournamentServer],
classOf[RegisterServerRequest],
classOf[ExternalTournamentServerList],
),
)
class NativeReflectionConfig
@@ -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])
@@ -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
@@ -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()
@@ -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)
@@ -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))